API reference
Generated from source docstrings. Everything here is importable from novaos_client_sdk directly (from novaos_client_sdk import Client) unless noted otherwise.
Agent building
novaos_client_sdk.agent_builder
AgentBuilder — The main entry point for building NovaOS agents.
Usage
from novaos_client_sdk import AgentBuilder, AgentExecutionContext, SkillResult
agent = AgentBuilder(name="my-agent", version="1.0.0")
@agent.skill("greet", description="Greet a user") async def greet(ctx: AgentExecutionContext, input_text: str) -> SkillResult: return SkillResult(output=f"Hello!")
Ported from NovaOs/packages/agent-sdk/python/novaos_sdk/agent.py Renamed from Agent to AgentBuilder to avoid conflict with the existing novaos_client_sdk.models.Agent model class.
Protocol: A2A v1.0.1, single-protocol. handle_a2a_request replaced the
ACP-era handle_acp_request outright, reflecting a deliberate migration
decision. There is deliberately no ACP compatibility path and no
dual-protocol serving, for two reasons that decision settles: BeeAI's
successor SDK ships no ACP-compatibility shim, so an agent cannot honestly
serve both; and NovaOS took an explicit product decision not to stay backward
compatible with ACP. A pod running this handler speaks A2A and nothing else,
which is why it must reach production in the same coordinated cutover as the
Temporal worker's A2A rewrite.
This module serves no Agent Card. Agent pods are never
internet-reachable, so /.well-known/agent-card.json is hosted by the
platform's execution gateway on the agent's behalf. Emitting one here
would only be visible to callers already inside the cluster.
Dependency direction (DIP). Everything below is typed against NovaOS's own
abstractions — a2a_types, a2a_task, a2a_jsonrpc, agent_context,
agent_types. No vendor A2A/ACP library is imported, and none should be: an
agent's skill handlers are business logic and must not have a vendor wire type
in their signature. agentstack-sdk and a2a-sdk are both absent from
this module's imports by design, not by accident.
Skill dispatch: a Task for cancellability, an explicit context= for
ContextVar propagation. Both _execute_skill and
handle_a2a_streaming_request run a skill handler via
asyncio.create_task(handler(...), context=...) rather than a plain
await handler(...). The Task exists solely so CancelTask
has something to .cancel() — a bare await gives a concurrent
cancellation nothing to interrupt. That Task would otherwise run inside
its own copy of the ambient contextvars context (asyncio's default
for any new Task), so a skill body's ContextVar.set(...) would mutate a
throwaway copy invisible to the caller the moment handle_a2a_request
returns — exactly the kind of regression an earlier version of this module
introduced and later fixed. The fix is
to capture contextvars.copy_context() immediately before creating the
task, pass it in as that task's context, and — once the task is done,
however it finished — copy every var in that context back onto the ambient
one the caller resumes in. Both constraints hold at once: the skill is still
cancellable (cancelling a Task is independent of which context it runs
in), and a ContextVar.set() inside the skill body is visible again once
handle_a2a_request/handle_a2a_streaming_request returns. Reverting to
an inline await — trading this regression back for the earlier one — is
not an acceptable fix for either problem.
Interruption policy: which interruptions end a task, and which leave it
running. A skill's execution can be cut short three ways, and the
Temporal worker depends on the store answering each one truthfully, so the
contract is stated here rather than left implicit in the except clauses:
- An explicit
CancelTask— the one and only interruption that is a decision about the task._dispatch_cancel_taskwritesTASK_STATE_CANCELEDfirst, then preempts the running skill. The dispatchers below never overwrite that write, however the skill's coroutine ends afterward. - The skill's own coroutine ends in an
asyncio.CancelledErrornoCancelTaskasked for — the skill raised it itself, or something it awaited did. The harness writes nothing: the task stays at whatever state the skill last wrote (WORKING, typically), the SSE response closes cleanly with no terminal frame, andSendMessageanswers with the task's current, non-terminal snapshot. This is A2A's "stream ends with no terminal event" shape, which a scaffold-baseda2a-sdkexecutor produces by simply returning without publishing one. A prior version of this module labelled itCANCELEDand let the error propagate out of the response generator, which Starlette's task group swallowed as a cancellation — so the ASGI callable returned without ever completing its response (uvicorn: "ASGI callable returned without completing response"; the client:httpx.RemoteProtocolError). Both halves were wrong: the label mistook a skill giving up for a caller asking it to, and the abort made the shape impossible to produce on purpose — the fleet conformance probe exists to test the platform's handling of exactly this shape, and could not. - The request is abandoned while the skill runs — the client
disconnected, the ASGI request task was cancelled, or the response
generator was closed. This is not a decision about the task either: an
A2A task is a server-side resource that outlives any one request to it
(spec §3.1.6 gives
SubscribeToTaskprecisely so a caller can reconnect), and the platform's own worker never treated a dropped connection as a cancel signal — it stops watching the task, then resubscribes or deletes the pod outright. So the skill is not stopped and nothing terminal is written. The execution is detached (:meth:AgentBuilder._detach_execution): the skill'sTaskruns on, and a finisher task publishes its eventual outcome —COMPLETED/FAILED/whatever theSkillResultmaps to, or nothing under rule 2, or nothing under rule 1 if aCancelTaskreaches it meanwhile — through the sameTaskUpdater/EventQueuethe request would have, then closes the queue and unregisters both. AGetTaskin between reportsWORKING; aSubscribeToTaskattaches to the still-registered queue and sees the terminal frame when it comes.CancelTaskremains the way to actually stop a detached skill — and so does re-sending on the same task id: aSendMessage/SendStreamingMessagenaming a task whose execution is still live (detached or not) supersedes it (:meth:AgentBuilder._supersede_live_execution) before re-seeding — preempting the earlier skill and revoking itsTaskUpdater— so a caller that disconnected and retried never ends up with two skills writing to one task. A prior version cancelled the skill and wroteCANCELEDhere too — which turned every worker restart and every client timeout into a task that claimed it had been cancelled by someone.
Rule 2 is the "stream ends with no terminal event" shape and the reason a probe can now produce it; rule 3 is what keeps the store truthful while nobody is watching. Rule 1 is unchanged from the original cancellation behavior.
AgentBuilder(name: str, version: str = '0.1.0', *, mask_output_pii: bool = False, extensions: Iterable[A2AExtension] | None = None, task_store: TaskStore | None = None, push_notification_store: PushNotificationConfigStore | None = None, default_output_modes: Iterable[str] | None = None)
NovaOS Agent. Register skills with @agent.skill() decorator. Handle A2A requests with agent.handle_a2a_request().
Initialize the agent.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
name
|
str
|
Agent name. |
required |
version
|
str
|
Agent version. |
'0.1.0'
|
task_store
|
TaskStore | None
|
Where |
None
|
push_notification_store
|
PushNotificationConfigStore | None
|
Where this agent's push notification
registrations are persisted — the |
None
|
mask_output_pii
|
bool
|
When |
False
|
extensions
|
Iterable[A2AExtension] | None
|
A2A protocol extensions this agent understands.
Stating them at construction is deliberate: what an
agent declares on its card should be a property of how it was
built, not of which modules happened to be imported. See
:meth: |
None
|
default_output_modes
|
Iterable[str] | None
|
The media types this agent produces when a
skill declares no |
None
|
Raises:
| Type | Description |
|---|---|
ValueError
|
If two extensions claim the same URI, or one declares no URI at all. |
extensions: ExtensionRegistry
property
The A2A extensions this agent understands.
supports_push_notifications: bool
property
Whether this agent records push notification config, not whether anything delivers it.
True exactly when a
:class:~novaos_client_sdk.a2a_push_notification_store.PushNotificationConfigStore
was supplied at construction, mirroring :attr:task_store's always-
present default. That is the entire claim: the four
*TaskPushNotificationConfig methods will accept, persist, and read
back a registration instead of answering A2A's -32003. It says
nothing about whether a webhook is ever actually dialled — this SDK
has no sender (confirmed by investigation: one does not
exist here, deliberately: delivery is the platform gateway's job).
Whether a given deployment delivers depends on whether
that gateway sits in front of this pod, which this class has no way
to know.
The platform's gateway/card-generation layer is the
intended reader — it sets this pod's Agent Card
capabilities.pushNotifications from this property, so the card
cannot drift from what handle_a2a_request_with_headers actually
serves at this layer. A caller reading that flag as "push
notifications work end-to-end" is reading more into it than this
property, or the methods it gates, ever promised. This class never
generates or serves a card itself; it only exposes the fact
a card generator needs.
task_store: TaskStore
property
Where this agent's SendMessage-created tasks are persisted.
asgi_endpoint() -> Callable[[Any], Any]
Build a ready-made ASGI route handler for this agent's A2A endpoint.
Turns the hand-written route every scaffolded agent used to carry —
decode the body, branch on SendStreamingMessage/SubscribeToTask
vs. everything else, pick JSONResponse or StreamingResponse —
into one line:
from novaos_client_sdk import A2A_JSONRPC_PATH, AgentBuilder
agent = AgentBuilder(name="my-agent", version="1.0.0")
app.post(A2A_JSONRPC_PATH)(agent.asgi_endpoint())
The returned callable takes one Starlette-shaped request (anything
with an async .body() and a .headers mapping — a plain
starlette.requests.Request or the identical object FastAPI hands a
route, since FastAPI's Request/Response are Starlette's) and
returns a Starlette Response, so it mounts directly on either
framework's router.
Requires Starlette to be installed (FastAPI pulls it in transitively, so any FastAPI app already has it). Importing this module never requires it — only calling this method does.
Returns:
| Type | Description |
|---|---|
Callable[[Any], Any]
|
An async |
Raises:
| Type | Description |
|---|---|
ImportError
|
If Starlette is not installed. |
declared_extensions() -> list[AgentExtension]
The capabilities.extensions[] array for this agent's Agent Card.
This agent does not serve its own card — pods are not internet-reachable, so the platform's execution gateway publishes it and the Galaxy scaffold generates card content at build time. This returns the array for whichever of them is doing the publishing, so a declaration cannot drift from the registry that will actually negotiate it at request time.
handle_a2a_bytes(raw: bytes | str, headers: Any) -> dict[str, Any]
async
Handle one A2A JSON-RPC 2.0 request given as an undecoded HTTP body.
The bytes-first counterpart of :meth:handle_a2a_request, for a route
that has not yet called request.json() (or equivalent) itself. A
hand-rolled route that decodes the body before calling
handle_a2a_request lets that decode step raise past it — the
framework turns a malformed body into an HTTP 500 instead of the
JSON-RPC PARSE_ERROR (-32700) the spec calls for — and every
scaffolded agent duplicated exactly that mistake. This
method, and :meth:asgi_endpoint built on it, exist so the decode is
the SDK's problem once rather than every wrapper's problem repeatedly.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
raw
|
bytes | str
|
The undecoded request body, as |
required |
headers
|
Any
|
Request headers; any mapping with |
required |
Returns:
| Type | Description |
|---|---|
dict[str, Any]
|
A JSON-RPC 2.0 response object, ready to serialize. |
dict[str, Any]
|
with |
dict[str, Any]
|
size limit); otherwise identical to what |
dict[str, Any]
|
meth: |
handle_a2a_bytes_with_headers(raw: bytes | str, headers: Any) -> tuple[dict[str, Any], dict[str, str]]
async
Handle one A2A request given as an undecoded HTTP body, with headers.
The bytes-first counterpart of :meth:handle_a2a_request_with_headers;
see :meth:handle_a2a_bytes for why the decode belongs here rather
than in a route. Decoding is the only step added — a body that
decodes successfully is handled identically to calling
handle_a2a_request_with_headers(decoded_body, headers) directly.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
raw
|
bytes | str
|
The undecoded request body, as |
required |
headers
|
Any
|
Request headers; any mapping with |
required |
Returns:
| Type | Description |
|---|---|
dict[str, Any]
|
|
dict[str, str]
|
|
tuple[dict[str, Any], dict[str, str]]
|
can have happened for a body that was never read as JSON. |
handle_a2a_request(body: Any, headers: Any) -> dict[str, Any]
async
Handle one A2A JSON-RPC 2.0 request and return the response envelope.
The body-only form of :meth:handle_a2a_request_with_headers, which is
what an agent serving no extensions needs. An agent that does serve
extensions should call that method instead and put the response headers
it returns on the wire, so a caller can see what its
A2A-Extensions request actually activated.
Implements the SendMessage operation of A2A spec v1.0.1: builds
the execution context, routes to a skill, runs it, and returns the
resulting Task. Creates an OTel span around skill execution and
wires EvaluationContext when the evaluation module is installed.
Two distinct failure layers, kept distinct on purpose:
- A malformed or unsupported call gets a JSON-RPC
errorresponse — the request never became a task, so there is no task to report a state for. - A call that was well-formed but could not be served gets a JSON-RPC
resultholding a task inTASK_STATE_REJECTED(unroutable) orTASK_STATE_FAILED(the skill raised). This matches what the Galaxy scaffold's vendor-SDK executor produces for the same conditions, so the Temporal worker handles both kinds of pod identically.
Version negotiation is spec-strict, including for an absent header.
A2A v1.0 §7 makes A2A-Version
mandatory and defines an absent or empty header as an explicit request
for 0.3, so a header-less call is answered -32009
(:class:~novaos_client_sdk.exceptions.VersionNotSupportedError), not
served. The alternative — accepting an absent header as 1.0 during
the cutover — was considered and rejected: it is the exact
looks-compliant-but-is-not shape found elsewhere, and because a
scaffolded pod already rejects those calls, being lenient here would
preserve the two-kinds-of-pod divergence this handler is meant to
close. It costs nothing because handle_a2a_request has no caller
yet — the Temporal worker's A2A rewrite, the first one, is written
against this contract.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
body
|
Any
|
The JSON-decoded request body |
required |
headers
|
Any
|
Request headers; any mapping with |
required |
Returns:
| Type | Description |
|---|---|
dict[str, Any]
|
A JSON-RPC 2.0 response object, ready to serialize. |
handle_a2a_request_with_headers(body: Any, headers: Any) -> tuple[dict[str, Any], dict[str, str]]
async
Handle one A2A request, returning its response body and headers.
The same work as :meth:handle_a2a_request, which delegates here. The
second return value exists because A2A's extension negotiation is
two-way: a caller names the extensions it wants in the
A2A-Extensions request header, and the agent answers with the ones
actually in force. That answer is a header, and a method returning only
a JSON-RPC body has nowhere to put it — hence the second form rather
than a changed return type on the first, which every existing pod route
already calls.
Extension negotiation is read off the original headers object,
not the lower-cased dict the rest of this handler uses. A2A-Extensions
is a list-valued header, so a caller may send it as several header lines;
flattening a multi-map into a dict keeps only one of them, and the
others would silently fail to activate.
An extension this agent does not implement is ignored, not refused.
The one hard failure is the mirror image: an extension this agent
declared required that the caller did not activate is answered
-32008
(:class:~novaos_client_sdk.exceptions.ExtensionSupportRequiredError),
which is exactly what that code is for. It is checked after version
negotiation, since a v0.3 caller's extension list is not worth reading.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
body
|
Any
|
The JSON-decoded request body |
required |
headers
|
Any
|
Request headers; any mapping with |
required |
Returns:
| Type | Description |
|---|---|
dict[str, Any]
|
|
dict[str, str]
|
at least one extension is in force. |
handle_a2a_streaming_request(body: Any, headers: Any) -> tuple[dict[str, Any] | AsyncIterator[bytes], dict[str, str]]
async
Handle one A2A SendStreamingMessage or SubscribeToTask request over SSE.
This method originally served only SendStreamingMessage;
SubscribeToTask was added here rather than as a third entry point,
since it shares the same reason for being separate from
:meth:handle_a2a_request_with_headers: both return a byte stream
that must start flowing before anything is known about how it ends,
not a single JSON-RPC body. SendStreamingMessage's own behaviour
is unchanged by this addition.
Runs the skill live rather than buffering its result, which is the
entire point: :attr:AgentExecutionContext.tools calls made through
this request emit tool.call / tool.result onto the stream as
the skill makes them, not only once it returns. A pod that never
streams still gets tool visibility through the terminal message's
metadata part (:meth:_agent_message); this is what makes it live.
Return contract. Exactly one of two shapes:
(response_dict, response_headers)— envelope/version/extension negotiation failed, the method was neitherSendStreamingMessagenorSubscribeToTask, or aSendStreamingMessagerequest named an unroutable skill. Nothing has been sent yet, so this is answered as an ordinary JSON-RPC response, matching what :meth:handle_a2a_request_with_headerswould have returned for the same failure — a caller does not need to know which entry point it used to get a consistent error shape.(frame_iterator, response_headers)— negotiation succeeded and either the skill is running (or about to, forSendStreamingMessage) or a subscription is being served (forSubscribeToTask).frame_iteratoryields already SSE-encoded bytes (:func:novaos_client_sdk._a2a.sse_data_line), ready to hand an ASGIStreamingResponse(or equivalent) unmodified — this method owns the SSE framing so no two pod implementations format it slightly differently.
A route distinguishes the two with isinstance(result, dict).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
body
|
Any
|
The JSON-decoded request body |
required |
headers
|
Any
|
Request headers; any mapping with |
required |
Returns:
| Type | Description |
|---|---|
tuple[dict[str, Any] | AsyncIterator[bytes], dict[str, str]]
|
See "Return contract" above. |
register_extension(extension: A2AExtension) -> A2AExtension
Register one A2A protocol extension and return it.
The incremental counterpart of the extensions= constructor argument,
for an agent assembled in pieces the way @agent.skill assembles its
skills.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
extension
|
A2AExtension
|
The extension to add. |
required |
Returns:
| Type | Description |
|---|---|
A2AExtension
|
The extension, unchanged. |
Raises:
| Type | Description |
|---|---|
ValueError
|
If it declares no URI, or another extension already claimed the one it declares. |
skill(name: str, description: str = '', *, id: str | None = None, tags: Iterable[str] | None = None, input_modes: Iterable[str] | None = None, output_modes: Iterable[str] | None = None) -> Callable
Register a skill handler.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
name
|
str
|
The skill's display name ( |
required |
description
|
str
|
Human-readable description of what the skill does. |
''
|
id
|
str | None
|
The routing selector a caller sends as
:data: |
None
|
tags
|
Iterable[str] | None
|
Free-form category tags, for |
None
|
input_modes
|
Iterable[str] | None
|
Media types this skill accepts, for
|
None
|
output_modes
|
Iterable[str] | None
|
Media types this skill produces, for
|
None
|
to_agent_card(*, description: str = '', supported_interfaces: Iterable[AgentInterface] | None = None, streaming: bool = True, provider: AgentProvider | None = None, documentation_url: str | None = None, icon_url: str | None = None, security_schemes: dict[str, Any] | None = None, security_requirements: list[dict[str, Any]] | None = None, default_input_modes: Iterable[str] | None = None, default_output_modes: Iterable[str] | None = None) -> AgentCard
The Agent Card content for this agent.
This agent still serves no card of its own. As
this module's own docstring explains, agent pods are never
internet-reachable, so the platform's execution gateway publishes the
card and the Galaxy scaffold generates card content at build time.
This method returns an
:class:AgentCard object for whichever of them is doing the
publishing — no route is added and nothing here starts serving
/.well-known/agent-card.json. It takes the same posture
:meth:declared_extensions already takes for the
capabilities.extensions[] slice of the same document, extended to
the whole card.
Every registered skill is carried through under the same id
:meth:skill registered it as — self._skills is keyed by that id,
and :func:_resolve_skill looks up requests against that same dict —
so a card built here can never advertise a skillId this agent
would reject at request time. That drift is exactly the class of
defect this design exists to close.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
description
|
str
|
The card's top-level |
''
|
supported_interfaces
|
Iterable[AgentInterface] | None
|
Where the agent answers
( |
None
|
streaming
|
bool
|
Whether |
True
|
provider
|
AgentProvider | None
|
Who publishes the agent ( |
None
|
documentation_url
|
str | None
|
|
None
|
icon_url
|
str | None
|
|
None
|
security_schemes
|
dict[str, Any] | None
|
|
None
|
security_requirements
|
list[dict[str, Any]] | None
|
|
None
|
default_input_modes
|
Iterable[str] | None
|
The card-level default input media types
( |
None
|
default_output_modes
|
Iterable[str] | None
|
The card-level default output media types
( |
None
|
Returns:
| Type | Description |
|---|---|
AgentCard
|
A populated :class: |
AgentCard
|
|
AgentCard
|
|
AgentCard
|
attr: |
AgentCard
|
instance was constructed with a |
AgentCard
|
it can never drift from what |
AgentCard
|
|
AgentCard
|
actually serves. Per that property's own docstring, |
AgentCard
|
|
AgentCard
|
|
AgentCard
|
does not mean this pod delivers a webhook itself; delivery, |
AgentCard
|
where it happens, is the platform gateway's job. |
describe_exception(error: BaseException) -> str
"<type>: <message>" for a skill failure, or just "<type>" if it has no message.
str(error) alone drops the exception type and is empty for TimeoutError(),
so the type is always named. Builtins are bare (TimeoutError); anything else is
module-qualified so two same-named classes stay distinguishable.
novaos_client_sdk.agent_context
Context — The core object every NovaOS agent uses.
Provides
- ctx.tools.* — governed access to platform tools
- ctx.run_id — current execution run ID
- ctx.idempotency_key — dedup key; execution is AT-LEAST-ONCE (see below)
- ctx.tenant_id — tenant context (auto-propagated)
- ctx.history — earlier turns of this conversation, oldest first
- ctx.trace_id — W3C trace ID (auto-propagated)
- ctx.config — agent configuration
Delivery semantics — read this if your agent has side effects
Agent execution is at-least-once, not exactly-once. The platform retries a streaming execution whose stream ended without a terminal task state, and it cannot distinguish that from a stream that dropped after the agent finished its work. Your skill can therefore be invoked twice for one logical request.
If your skill writes to an external system, calls a paid API, or has any other
non-idempotent effect, deduplicate on ctx.idempotency_key::
async def handle(ctx):
key = ctx.idempotency_key
if key and await already_processed(key):
return await stored_result(key)
result = await do_the_side_effecting_work()
if key:
await record(key, result)
return result
Do not deduplicate on ctx.run_id. It is stable across retries but is not
unique per execution — a human-in-the-loop continuation reuses the run id of the
turn that raised the approval — so it would silently skip a legitimate second
execution.
Ported from NovaOs/packages/agent-sdk/python/novaos_sdk/context.py
AgentExecutionContext(execution_context: AgentRuntimeContext, *, enable_telemetry: bool = False, extensions: ExtensionActivation | None = None, verified_tenant_id: str = '', emit_tool_event: Callable[[str, dict[str, Any]], None] | None = None, task_updater: TaskUpdater | None = None, cancellation: _CancellationToken | None = None, referenced_tasks: tuple[Task, ...] = (), unresolved_reference_task_ids: tuple[str, ...] = (), custom_headers: Mapping[str, str] | None = None)
Agent execution context. Created automatically by the AgentBuilder class
from the A2A request headers and SendMessage params.
Access platform tools via ctx.tools: result = await ctx.tools.knowledge.search("compliance regulations") result = await ctx.tools.agents.call("agent-id", {"input": "..."})
buffered_events: tuple[tuple[str, dict[str, Any]], ...]
property
Platform events recorded via :meth:_emit_platform_event on a non-streaming request.
Always empty on a streaming request, since those events went straight
to the SSE queue instead — see :meth:_emit_platform_event.
context_id: str
property
A2A contextId grouping this task with related ones.
A2A's name for what ACP called session_id (ADR F2); :attr:session_id
returns the same value for callers that still use the older name.
custom_headers: dict[str, str]
property
The caller's custom_headers for this request, lower-cased and filtered.
Service-account callers pass custom_headers on execute; the platform
delivers them as request headers. Only names that pass
:func:~novaos_client_sdk.header_safety.is_safe_custom_header_name are
kept, so platform-owned headers (Authorization, X-NovaOS-*,
traceparent, ...) never appear here. Every ctx.tools.* call
already forwards them, so a skill only needs this to read a value.
Returns a copy. Values may carry credentials (an end-user JWT): do not log them.
extensions: ExtensionActivation
property
The A2A protocol extensions in force for this request.
A skill asks ctx.extensions.is_active(uri) whether an agreement
applies and ctx.extensions.value(uri) for whatever data it carried;
the dispatch to the extension that owns the URI happens inside the
activation, so a skill never has to know which metadata map the payload
arrived in.
Always an :class:~novaos_client_sdk.a2a_extensions.ExtensionActivation
— :data:ExtensionActivation.EMPTY for a request negotiated by an agent
with no extensions, or for a context built by hand — so a skill has no
None to guard against.
history: tuple[ConversationTurn, ...]
property
Earlier turns of this conversation, oldest first.
The A2A-era replacement for reading ACP's input: Message[] beyond
its last entry. SendMessage carries exactly one message, so the
platform sends prior turns structurally in the NovaOS context instead;
this is where they are read. The current turn is not included — it
still arrives as the skill's input text — so a skill that ignores this
property behaves exactly as it did before.
Empty is the common case, and it does not mean "first turn." The
platform only carries history for a run that belongs to a conversation
thread: the marketplace chat surface, and a service-account run that
named a thread_id. A task, a team step, a background run, a
single-shot service-account run, an agent-to-agent call and a
hand-written request all arrive with none. Read an empty history as no
prior context was supplied, and never infer from it that the
conversation has no past.
Not A2A's contextId mechanism, and deliberately so. A2A expects
a server to recover earlier turns from its own task store, keyed on
contextId. No NovaOS pod can: AgentBuilder's task store
is in-process and dies with the pod exactly as a
scaffolded pod's InMemoryTaskStore does, and an ephemeral run gets
a fresh pod for every turn. :attr:context_id therefore still
correlates the turns — it is just not something a pod can look
anything up in today.
Returns:
| Type | Description |
|---|---|
ConversationTurn
|
The turns as the platform sent them, oldest first; |
...
|
arrived or none were readable. |
Example::
from dataclasses import asdict
@agent.skill("chat")
async def chat(ctx, user_input):
messages = [{"role": "system", "content": SYSTEM_PROMPT}]
messages += [asdict(turn) for turn in ctx.history]
messages.append({"role": "user", "content": user_input})
return SkillResult(output=await call_llm(messages))
idempotency_key: str
property
Stable dedup key for this execution — see :class:AgentRuntimeContext.
Agent execution is at-least-once: the same logical request can arrive
twice. A skill with side effects should record this key alongside the
effect and skip a request whose key it has already handled. Empty when
the platform sent none; do not dedupe on :attr:run_id instead, which
a HITL continuation reuses.
is_cancelled: bool
property
Whether CancelTask has requested cancellation of this execution.
Poll this in a skill doing synchronous, non-awaiting work — a tight
compute loop, a blocking library call between awaits — since only an
await point can be interrupted by asyncio task cancellation alone.
A skill that only ever awaits (I/O, asyncio.sleep, an async HTTP
client) does not need to check this itself: AgentBuilder cancels
the asyncio.Task running the skill, which delivers
asyncio.CancelledError at the skill's next await regardless of
whether it ever looks at this property.
See :meth:raise_if_cancelled for the raise-instead-of-check form.
logger: logging.Logger
property
Structured JSON logger pre-bound to this execution's run_id, agent_id, and tenant_id.
Each line emitted via this logger is a newline-delimited JSON object
compatible with the Loki/Promtail ingestion pipeline. The
marketplace Agent Log Panel's Run ID filter relies on the
run_id field being present in every log line.
Usage::
ctx.logger.info("Tool call completed")
ctx.logger.error("Upstream failed", extra={"tool": "search", "status": 503})
ctx.logger.debug("Parsed %d items", len(items))
Returns:
| Type | Description |
|---|---|
Logger
|
A |
mcp_write_hitl_active: bool
property
Mint-time signal: does this run's token carry a write-capable
mcp:* scope? See :class:AgentRuntimeContext for the full
contract, including why this is always False for a
service-account run and why it is a hint rather than a guarantee.
For a precise, per-tool answer instead of this coarse run-level one,
use ctx.tools.hitl.requires_write_hitl(tool_fqn).
memory: MemoryNamespace
property
Pre-fetched memory context, and a factory for on-demand MemoryClient calls.
ctx.memory.context is the :class:~novaos_client_sdk.memory.AssembleResult
:class:~novaos_client_sdk.agent_builder.AgentBuilder's pre-fetch hook
assembled before your skill ran — None if the memory
capability isn't declared for this agent, or if pre-fetch failed
(logged, never raised into your skill).
Backed by the same :class:~novaos_client_sdk.context.MemoryNamespace
the lower-level :class:~novaos_client_sdk.context.ExecutionContext
uses, constructed once and cached on this instance — unlike
:meth:to_execution_context, which builds a fresh
ExecutionContext (and so a fresh, empty namespace) on every call,
this property must return the same namespace every time so a value
the pre-fetch hook sets is still there when your skill reads it.
memory_budget: dict[str, int] | None
property
The pre-fetch budget temporal-worker attached, or None.
None means this agent's catalog manifest does not declare the
memory capability — :class:~novaos_client_sdk.agent_builder.AgentBuilder's
pre-fetch hook reads this property to decide whether to call
memory.assemble at all, so a skill does not need to check this
itself unless it wants to know why :attr:memory came back empty.
output_schema: dict[str, Any] | None
property
Caller-supplied JSON Schema for structured extraction (Ex10), if any.
None when the caller didn't supply one. Use with
:func:novaos_client_sdk.schema_validation.validate_against_schema
to check a skill's extracted output against it before returning.
referenced_tasks: tuple[Task, ...]
property
This message's referenceTaskIds, resolved to Task objects.
A2A lets a message point at other tasks it references — a follow-up
question about a prior result, a correction to one — via
message.referenceTaskIds. AgentBuilder resolves each id
against its own task_store before this context is built (see
:meth:from_a2a_request's own docstring for why resolving is
dispatch's job, not this class's), so a skill reads already-resolved
Task objects here rather than ids it would have to look up itself
— this SDK gives a skill no other way to reach task_store at all.
Same-context only. A pod's task store carries no tenant field of
its own (:class:~novaos_client_sdk.a2a_task.Task has none), so
contextId equality — A2A's own session/conversation grouping,
and the closest boundary this store's data model actually has — is
what stands in for "no cross-tenant reads" here: a reference naming a
task from a different contextId than this request's own is
treated exactly like a reference naming an id the store has never
seen, and is skipped rather than resolved.
Never raises. An id that does not resolve — unknown to this pod,
evicted by the store's own LRU cap, or scoped to a different
contextId — is silently omitted from this tuple and reported
instead in :attr:unresolved_reference_task_ids, so one bad or
stale reference cannot fail an otherwise-servable request. Empty when
the message carried no referenceTaskIds at all, which is the
common case.
runtime_context: AgentRuntimeContext
property
The underlying runtime context injected by the platform.
secret_names: tuple[str, ...]
property
Every secret name this request delivered, sorted; empty when none were.
Safe to log — names travel in the clear on the wire by design, since a
pod must be able to route a value before it can decrypt one. Worth
logging when :meth:get_secret raises, because it separates "this
instance declares no such secret" from "delivery is not working".
task_id: str
property
A2A Task.id for this execution.
Correlates with :attr:run_id but is not the same identifier — see
:class:~novaos_client_sdk.agent_types.AgentRuntimeContext.
task_updater: TaskUpdater | None
property
Report progress on this task mid-execution.
Call :meth:~novaos_client_sdk.a2a_task_updater.TaskUpdater.start_work,
:meth:~novaos_client_sdk.a2a_task_updater.TaskUpdater.add_artifact, or
:meth:~novaos_client_sdk.a2a_task_updater.TaskUpdater.update_status
(with :attr:~novaos_client_sdk.a2a_task.TaskState.WORKING) from inside
a long-running skill instead of staying silent until it returns.
Prefer returning a terminal SkillResult over calling one of its
terminal methods (complete, failed, reject, cancel)
yourself — :class:~novaos_client_sdk.agent_builder.AgentBuilder
closes the task itself once your handler returns, from whatever
SkillResult you return, and you must return one regardless. You
may call a terminal method directly if you have your own reason to
— most concretely, to close the task before you have
finished building the SkillResult you still have to return. Doing
so is safe: your direct call wins over the harness's own decision when
the two disagree, rather than silently being overwritten by it — see
:meth:~novaos_client_sdk.agent_builder.AgentBuilder._finalize_task's
docstring for the precedence reasoning. Calling one is still rarely
useful, though, since you have nothing to gain that returning the
equivalent SkillResult.status ("REJECTED", and so on) does not
already give you more simply.
None for a request that arrived through SendStreamingMessage,
which reports progress through SSE frames directly rather than through
this object.
trace_id: str
property
The bare 32-hex W3C trace-id, or "" when none was propagated.
Extracted from the inbound traceparent header (falling back to a
platform-supplied trace_id when no header arrived). This is the id
a trace backend keyed on trace_id expects — for the full header
needed to propagate trace context on outbound calls, see
:attr:traceparent.
traceparent: str
property
The full inbound W3C traceparent header, or "" when none arrived.
unresolved_reference_task_ids: tuple[str, ...]
property
referenceTaskIds this request named that :attr:referenced_tasks could not resolve.
Every id here is either unknown to this pod's task_store or
belongs to a different contextId than this request's own — see
:attr:referenced_tasks's docstring for why the second case is
treated the same as the first. A skill that needs to tell a caller
"I couldn't find what you referenced" reads this rather than
diffing the message's own referenceTaskIds against
:attr:referenced_tasks by hand.
from_a2a_request(message: A2AMessage, params: dict[str, Any], headers: dict[str, str], *, task_id: str = '', extensions: ExtensionActivation | None = None, emit_tool_event: Callable[[str, dict[str, Any]], None] | None = None, task_updater: TaskUpdater | None = None, cancellation: _CancellationToken | None = None, referenced_tasks: tuple[Task, ...] = (), unresolved_reference_task_ids: tuple[str, ...] = ()) -> AgentExecutionContext
classmethod
Build a Context from an A2A SendMessage request.
Replaces the ACP-era from_acp_request. The platform context no longer
arrives as a top-level body["context"] object — A2A's request shape has
no room for one — so it is read out of the request and message metadata
maps under :data:NOVAOS_CONTEXT_METADATA_KEY.
A2A's own protocol correlators are read from the message itself, not from the
NovaOS context, so they stay authoritative even when a caller sends no NovaOS
context at all: message.context_id is the A2A contextId, and
task_id is supplied by the caller of this method (AgentBuilder resolves
it, since choosing between a continued task and a fresh one is dispatch's
business, not the context's).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
message
|
A2AMessage
|
The already-parsed inbound A2A |
required |
params
|
dict[str, Any]
|
The |
required |
headers
|
dict[str, str]
|
Lower-cased HTTP request headers |
required |
task_id
|
str
|
The A2A |
''
|
extensions
|
ExtensionActivation | None
|
The outcome of this request's |
None
|
emit_tool_event
|
Callable[[str, dict[str, Any]], None] | None
|
Forwarded to :meth: |
None
|
task_updater
|
TaskUpdater | None
|
Forwarded to :meth: |
None
|
cancellation
|
_CancellationToken | None
|
Forwarded to :meth: |
None
|
referenced_tasks
|
tuple[Task, ...]
|
Forwarded to :meth: |
()
|
unresolved_reference_task_ids
|
tuple[str, ...]
|
Forwarded to :meth: |
()
|
Returns:
| Type | Description |
|---|---|
AgentExecutionContext
|
A populated |
get_secret(name: str, default: Any = _NO_SECRET_DEFAULT) -> Any
The value of one secret delivered with this request.
The A2A-era replacement for ACP's context.secrets. The worker seals
each value to this pod's recipient key before placing it in the request
(the extension mechanism provides no confidentiality of its
own), and this is where it is opened, inside the agent process, past the
auth-proxy sidecar.
Nothing is cached. Each call opens the envelope again and hands back a fresh string; the plaintext lives only in whatever the caller does with it. A skill that needs a value more than once should keep it.
A default covers a missing secret, never a failed one. If the value was delivered and would not open — a rotated key, a tampered payload, a binding that does not match this run — the exception propagates whether or not a default was supplied. Substituting a default there would turn a key or replay problem into an agent quietly running with the wrong credential, which is the failure this whole subsystem exists to prevent.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
name
|
str
|
The secret's name, as the instance declares it. |
required |
default
|
Any
|
Returned when no secret of that name was delivered.
Omit it to get :class: |
_NO_SECRET_DEFAULT
|
Returns:
| Type | Description |
|---|---|
Any
|
The secret value, or |
Raises:
| Type | Description |
|---|---|
SecretNotDeliveredError
|
Nothing arrived under |
RecipientKeyUnavailableError
|
The envelope names a key this pod does not hold. |
SealedEnvelopeError
|
The envelope did not open. |
Example::
@agent.skill("send")
async def send(ctx, text):
password = ctx.get_secret("SMTP_PASSWORD")
region = ctx.get_secret("AWS_REGION", "eu-west-1")
raise_if_cancelled() -> None
Raise asyncio.CancelledError if CancelTask has requested cancellation.
The convenience form of :attr:is_cancelled for a loop that would
otherwise have to check the flag and raise by hand on every
iteration::
@agent.skill("bulk-process")
async def bulk_process(ctx, input_text):
for item in items:
ctx.raise_if_cancelled()
process(item) # synchronous, no `await` of its own
return SkillResult(output="done")
Raises the same exception type asyncio itself raises at an
interrupted await, so a skill's own cleanup does not need two
different cases for "cancelled while awaiting" versus "cancelled
while polling".
to_execution_context() -> ExecutionContext
Convert to an ExecutionContext for use with the lower-level SDK APIs.
AgentExecutionContext is the A2A-builder-facing context (constructed from
an A2A SendMessage request + headers). ExecutionContext is the
lower-level dataclass used by context.py / MemoryClient /
auth-header utilities.
D3 fix: prior code had no bridge between the two context types, so agents
using the builder pattern could not access ctx.memory via the
ExecutionContext interface without duplicating header parsing logic.
Returns:
| Type | Description |
|---|---|
ExecutionContext
|
An |
Example::
# Inside a skill handler
lower = ctx.to_execution_context()
mem_headers = lower.to_auth_headers()
# mem_headers == {"Authorization": "Bearer <execution_token>"}
novaos_context_from_metadata(*metadata: dict[str, Any] | None) -> dict[str, Any]
Pull the NovaOS platform context out of the first metadata map that has it.
A2A gives a caller two places to attach metadata to a SendMessage — the
request params and the message itself. The platform sets it on the
request; a caller constructing a message by hand may well attach it to the
message. Both are accepted, in the order given, so neither placement is a
silent no-op.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
*metadata
|
dict[str, Any] | None
|
Candidate metadata maps, most-authoritative first |
()
|
Returns:
| Type | Description |
|---|---|
dict[str, Any]
|
The context object, or an empty dict when no map carries one. |
novaos_client_sdk.context
Platform execution context utilities for NovaOS agents.
Provides helpers to extract the execution context injected by the novaos-auth-proxy sidecar and to build outbound headers for agent-to-agent or agent-to-MCP calls.
The auth-proxy sidecar validates RS256 execution tokens and injects the following headers into every request that reaches the agent container:
X-NovaOS-User-IdX-NovaOS-Tenant-IdX-NovaOS-Run-IdX-NovaOS-Agent-IdX-NovaOS-Instance-IdX-NovaOS-Execution-Token(the original Bearer token, for outbound calls)
Example
from novaos_client_sdk.context import ExecutionContext
# Inside an A2A agent handler
ctx = ExecutionContext.from_headers(request.headers)
print(ctx.user_id, ctx.run_id)
# Build outbound headers for calling another agent or MCP Gateway
outbound = ctx.to_auth_headers()
# outbound == {"Authorization": "Bearer <token>"}
ExecutionContext(user_id: str | None = None, tenant_id: str | None = None, run_id: str | None = None, agent_id: str | None = None, instance_id: str | None = None, execution_token: str | None = None)
dataclass
Typed representation of the NovaOS execution context.
Populated from the X-NovaOS-* headers that the auth-proxy sidecar injects
after validating the RS256 execution token.
Attributes:
| Name | Type | Description |
|---|---|---|
user_id |
str | None
|
The authenticated user ID (from token |
tenant_id |
str | None
|
The tenant/organization ID (from token |
run_id |
str | None
|
The current execution run ID (from token |
agent_id |
str | None
|
The target agent ID (from token |
instance_id |
str | None
|
The agent instance/pod ID (from token |
execution_token |
str | None
|
The raw RS256 execution token for outbound calls. |
memory: MemoryNamespace
property
Access the memory namespace for this execution context.
Returns a MemoryNamespace that lazily initializes a MemoryClient using the execution token from this context.
from_headers(headers: Any) -> ExecutionContext
classmethod
Extract execution context from request headers.
Works with plain dicts, Starlette Headers, Flask EnvironHeaders,
or any object that supports get(name) or dict-style iteration.
If X-NovaOS-Execution-Token is absent from the headers (e.g. the
agent is running outside the auth-proxy sidecar), the method falls back
to the NOVAOS_EXECUTION_TOKEN environment variable before returning
None. This allows local-dev and test-harness usage without the
auth-proxy while keeping production behaviour unchanged.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
headers
|
Any
|
Request headers mapping. |
required |
Returns:
| Type | Description |
|---|---|
ExecutionContext
|
ExecutionContext populated from the |
require_token() -> str
Return the execution token, raising if it is absent.
Use this instead of reading execution_token directly whenever an
authenticated outbound call is about to be made. It surfaces the
missing-token problem at the earliest possible point with an actionable
error message rather than failing later with a cryptic 401.
Returns:
| Type | Description |
|---|---|
str
|
The non-empty execution token string. |
Raises:
| Type | Description |
|---|---|
MissingExecutionTokenError
|
When |
to_auth_headers() -> dict[str, str]
Build outbound Authorization header from the execution token.
Use this when your agent needs to call another agent (via A2A) or the MCP Gateway. The target's auth-proxy will validate the token.
Returns:
| Type | Description |
|---|---|
dict[str, str]
|
Dict with |
dict[str, str]
|
otherwise an empty dict. |
to_context_headers() -> dict[str, str]
Build a complete set of X-NovaOS-* headers for outbound propagation.
Useful when you need to forward the full execution context (not just the token) to a downstream service.
Returns:
| Type | Description |
|---|---|
dict[str, str]
|
Dict of |
MemoryNamespace(ctx: ExecutionContext)
Memory operations namespace attached to an ExecutionContext.
Provides a context property for pre-fetched memory (set by Agent Runtime
before the agent handler runs) and a client() factory for creating
a MemoryClient bound to this execution context.
context: AssembleResult | None
property
writable
Pre-fetched context assembled by Agent Runtime before agent code runs.
Returns None if memory pre-fetch is disabled or the Agent Runtime did not inject context.
client(**kwargs: Any) -> MemoryClient
Create a MemoryClient bound to this execution context.
The client inherits the execution token for authentication.
Returns:
| Type | Description |
|---|---|
MemoryClient
|
MemoryClient configured with auth headers from this context. |
Raises:
| Type | Description |
|---|---|
MissingExecutionTokenError
|
When no execution token is available. |
novaos_client_sdk.agent_types
Core types for the NovaOS Agent SDK.
Ported from NovaOs/packages/agent-sdk/python/novaos_sdk/types.py
AgentRuntimeContext(run_id: str = '', thread_id: str = '', user_id: str = '', tenant_id: str = '', instance_id: str = '', trace_id: str = '', span_id: str = '', traceparent: str = '', session_id: str = '', execution_token: str = '', mcp_gateway_url: str = '', mcp_gateway_token: str = '', gateway_url: str = '', config: dict[str, Any] = dict(), output_schema: dict[str, Any] | None = None, idempotency_key: str = '', task_id: str = '', context_id: str = '', history: tuple[ConversationTurn, ...] = (), mcp_write_hitl_active: bool = False, memory_budget: dict[str, int] | None = None)
dataclass
Platform execution context injected at runtime via the request body/headers.
Distinct from context.ExecutionContext, which parses X-NovaOS-* auth-proxy
headers. This class holds the NovaOS platform fields (run_id, mcp_gateway_url,
config, etc.) plus the A2A protocol correlators, extracted from an A2A
SendMessage request, and is used exclusively by AgentBuilder /
AgentExecutionContext.
Why NovaOS ids and A2A ids both appear here (ADR F13). Before the A2A
migration this class documented itself as holding "ACP protocol fields", and the
obvious move on migrating was to rename run_id to task_id and
session_id to context_id. That would have been wrong. A NovaOS run is
a platform record with its own lifecycle — the marketplace Agent Log Panel's Run
ID filter and the Loki ingestion pipeline both key on it —
whereas an A2A task is a protocol object owned by whichever agent serves the
request. They correlate; they are not the same identifier. So both are carried
and the correspondence is recorded rather than collapsed:
================ ================ ==================================================
NovaOS field A2A field Relationship
================ ================ ==================================================
run_id task_id AgentBuilder seeds a new task's id from
run_id when the caller supplied none, so the
two match for platform-originated runs.
session_id context_id A2A's contextId groups related tasks; ACP's
session_id meant the same thing (ADR F2).
================ ================ ==================================================
ConversationTurn(role: str, content: str)
dataclass
One earlier turn of the conversation this execution belongs to.
Why this type exists at all. Under ACP the platform posted
input: Message[] and an agent could simply read the earlier entries.
A2A's SendMessage carries exactly one message, so there is nowhere in
the request shape for prior turns to live. Folding them into the current
message would corrupt it — every agent written against ACP reads the last
message as its input, and Message.text concatenates every text part, so
sibling parts would glue the transcript onto the question with the roles
lost. The platform therefore carries prior turns structurally, in the
NovaOS context metadata, and they surface here as
:attr:~novaos_client_sdk.agent_context.AgentExecutionContext.history.
This is not A2A's own multi-turn mechanism, deliberately. A2A groups
related tasks with contextId and expects the server to recover earlier
turns from its own task store. That does not work across NovaOS runs
today: AgentBuilder's task store, like a scaffolded pod's
InMemoryTaskStore, is in-process and does not outlive the pod, and an
ephemeral run gets a fresh pod anyway. Until a durable, pod-external task
store exists, contextId correlates turns but cannot reconstruct them.
Attributes:
| Name | Type | Description |
|---|---|---|
role |
str
|
Who produced the turn, in the conversation service's own
vocabulary — |
content |
str
|
The turn's plain text. May be empty for a turn that carried
no text (an attachment-only message, say); it is never |
Example — prepending the transcript to an LLM call::
from dataclasses import asdict
messages = [{"role": "system", "content": "You are a helpful agent."}]
messages += [asdict(turn) for turn in ctx.history]
messages.append({"role": "user", "content": user_input})
SkillResult(output: Any, status: str = 'COMPLETED', error: str | None = None, metadata: dict[str, Any] = dict())
dataclass
Result of a skill execution.
status stays in NovaOS's own vocabulary rather than becoming an A2A
:class:~novaos_client_sdk.a2a_task.TaskState. That is the DIP requirement
working as intended: a skill handler is agent-authored business logic and
should not have to know which wire protocol its result is serialized into.
:class:~novaos_client_sdk.agent_builder.AgentBuilder owns the translation:
================== ==============================
SkillResult A2A TaskState
================== ==============================
COMPLETED TASK_STATE_COMPLETED
FAILED TASK_STATE_FAILED
WAITING_USER TASK_STATE_INPUT_REQUIRED
REJECTED TASK_STATE_REJECTED
AUTH_REQUIRED TASK_STATE_AUTH_REQUIRED
================== ==============================
WAITING_USER mapping onto a first-class protocol state is the point of
ADR F4: under ACP the pause was a custom awaiting_approval field that
only NovaOS understood.
Legacy status spellings are also accepted, deliberately.
QUEUED, RUNNING, IN_PROGRESS and ERROR are not
part of this vocabulary — nothing ever designed them — but real agent
code returns them anyway (analysis of a real agent fleet
found all four in agent code that had never seen this table). Each has
exactly one unambiguous A2A meaning, so
:data:~novaos_client_sdk.agent_types._SKILL_STATUS_TO_TASK_STATE maps
them too: QUEUED -> TASK_STATE_SUBMITTED, RUNNING/
IN_PROGRESS -> TASK_STATE_WORKING, ERROR -> TASK_STATE_FAILED.
A QUEUED/RUNNING/IN_PROGRESS return leaves the task open
— the handler has already returned by the time this mapping runs, so
"submitted" or "working" can only mean the work continues outside this
request (a background job a separate progress skill or TaskUpdater
call will later close). A status outside this expanded vocabulary is no
longer silently treated as COMPLETED (that was the bug this fixes):
it fails the task instead, with the offending value named in both an
ERROR-level log and the task's own status message — see
:meth:~novaos_client_sdk.agent_builder.AgentBuilder._task_state_for.
This is not the same shape as WAITING_USER/AUTH_REQUIRED, and
that difference matters.
:data:~novaos_client_sdk.a2a_task.INTERRUPT_TASK_STATES — and therefore
:func:~novaos_client_sdk.a2a_events.is_last_frame — knows about
INPUT_REQUIRED/AUTH_REQUIRED, so a caller reading a
SendStreamingMessage reply correctly recognises those as a clean,
deliberate pause. SUBMITTED/WORKING carry no such recognition —
to a stream reader they are indistinguishable from a connection that
dropped mid-execution, which is exactly the ambiguity
:class:~novaos_client_sdk.agent_context.AgentExecutionContext's own
"Delivery semantics" section already documents: the platform retries a
streaming execution whose stream ended without a terminal task state,
so a skill returning QUEUED/RUNNING/IN_PROGRESS can be
invoked again for what the platform believes might be the same logical
request. This is not a new hazard this vocabulary introduces — any skill left at
WORKING when its stream drops already faces it — but choosing one of
these three statuses is choosing to hit it every time, not
occasionally. A skill using them for a non-idempotent effect (enqueuing
a job, most concretely) must deduplicate on ctx.idempotency_key,
exactly as that section instructs for any other non-idempotent skill.
REJECTED means "this was never going to work," not "it failed",
the same distinction :meth:~novaos_client_sdk.agent_builder.AgentBuilder._rejected
already draws for a request an agent never even dispatched to a skill —
an unroutable skillId, say. A caller can retry a FAILED task;
retrying a REJECTED one without changing the request is pointless.
Before this member existed, a skill that discovered mid-run that a
request could never succeed had no way to say so — the best it could
reach was FAILED, which is indistinguishable from "the agent tried
and broke."
AUTH_REQUIRED means "go authorise something out of band," not
"answer a question", the same distinction A2A itself
draws between :attr:~novaos_client_sdk.a2a_task.TaskState.AUTH_REQUIRED
and :attr:~novaos_client_sdk.a2a_task.TaskState.INPUT_REQUIRED. A
connector-OAuth pause or a write-confirmation gate — the shapes
:meth:~novaos_client_sdk.message_builder.MessageBuilder.create_oauth_required_message
and this SDK's A2A pause carrier exist for — is fundamentally different from
a clarifying question: the caller cannot resolve it by simply replying
with text, it has to go complete an out-of-band step (visit an OAuth URL,
approve a write) first. Before this member existed, a skill reaching this
pause had no way to say so distinctly and had to return WAITING_USER
and hope the caller inferred the difference from the message body alone.
Like INPUT_REQUIRED, this is an interrupt, not a terminal
(:data:~novaos_client_sdk.a2a_task.INTERRUPT_TASK_STATES): the task
stays updatable, and a caller resumes it with a follow-up SendMessage
naming the same task id once the out-of-band step is done.
Precedence when a skill also calls ctx.task_updater itself. A
skill may call one of ctx.task_updater's terminal methods
(complete/failed/reject/cancel) directly instead of, or
in addition to, returning a SkillResult — see that attribute's own
docstring. When both happen and disagree,
:meth:~novaos_client_sdk.agent_builder.AgentBuilder._finalize_task
treats the direct call as authoritative and leaves it in place rather
than overwriting it with whatever this table maps the returned
SkillResult.status to. That direct call is the more specific,
deliberate signal; a returned SkillResult is what _finalize_task
falls back to when a skill hasn't already closed its own task. See
_finalize_task's docstring for the full reasoning and its history.
There is no CANCELED member, deliberately.
TASK_STATE_CANCELED never arises from a returned SkillResult —
it is always the outcome of an external CancelTask call, handled
entirely outside this dataclass: :class:~novaos_client_sdk.agent_builder.AgentBuilder
reads the task store directly once a skill's execution is interrupted or
known to be cancelled, rather than synthesizing a SkillResult to
carry it through this table. Adding a CANCELED member here would
invite a skill to return one, which makes no sense for a state a skill
never chooses for itself — cancellation is imposed on a skill by a
caller, not decided by the skill's own return value the way COMPLETED,
FAILED and WAITING_USER are.
What metadata becomes on the wire.
:meth:~novaos_client_sdk.agent_builder.AgentBuilder._agent_message folds
this dict into its own DataPart, tagged
:data:~novaos_client_sdk.agent_builder.NOVAOS_SKILL_METADATA_CONTENT_TYPE
— a marker distinct from the platform one
(:data:~novaos_client_sdk.agent_builder.NOVAOS_METADATA_CONTENT_TYPE,
which carries HITL/OAuth/write-confirmation pauses and error, above,
under :data:~novaos_client_sdk.agent_builder.NOVAOS_SKILL_ERROR_KEY).
The platform's part-draining (its wire contract's
drainParts) reads a platform-marker part for platform fields only and
never surfaces it as caller-visible run data; the skill-metadata marker
is treated like any other DataPart instead, so this field's contents
reach a caller through GET /api/v1/service/runs/{id}'s per-artifact
data. Before the two markers were split apart, this field shared the
platform marker with skill_error and was silently excluded from that
response — returning structured data via ctx.task_updater.add_artifact
with a plain, untagged DataPart was the only way to have it surface.
Both paths behave identically now; this field is simply the shortcut for
a skill that has nothing else to say about its own task lifecycle.
ToolResponse(success: bool, data: Any = None, error: str | None = None)
dataclass
Standardised response from a platform tool.
novaos_client_sdk.tools
Platform tool bindings for the NovaOS Agent SDK.
Each tool binding makes governed MCP Gateway calls with auto-injected auth tokens and tenant context. In sandbox mode (NOVAOS_ENV=sandbox), calls are routed to local mock services instead.
Tools
memory, knowledge, agents, hitl, files, notify, http, browser, code
Ported from NovaOs/packages/agent-sdk/python/novaos_sdk/tools.py Adapted to use httpx (already a dependency of novaos-client-sdk).
AgentsTool(mcp_gateway_url: str, token: str, tenant_id: str, *, default_timeout_s: int | None = None, gateway_timeout_s: int | None = None)
Bases: _BaseToolBinding
Agent-to-agent communication (governed, token-chained).
BatchIngestResponse(batch_id: str, total: int, items: list[IngestJobStatus])
dataclass
Aggregate response from :meth:KnowledgeTool.ingest_batch.
Attributes:
| Name | Type | Description |
|---|---|---|
batch_id |
str
|
Client-generated UUID correlating all items in this batch. |
total |
int
|
Number of items originally submitted. |
items |
list[IngestJobStatus]
|
Per-item statuses in input order. Poll or pass to
:meth: |
BrowserTool(mcp_gateway_url: str, token: str, tenant_id: str, *, default_timeout_s: int | None = None, gateway_timeout_s: int | None = None)
Bases: _BaseToolBinding
Browser automation.
CodeTool(mcp_gateway_url: str, token: str, tenant_id: str, *, default_timeout_s: int | None = None, gateway_timeout_s: int | None = None)
Bases: _BaseToolBinding
Sandboxed code execution.
ConnectorTool(mcp_gateway_url: str, token: str, tenant_id: str, *, default_timeout_s: int | None = None, gateway_timeout_s: int | None = None)
Bases: _BaseToolBinding
Generic connector-host invocation — the governed request/response path to third-party integrations (ClickUp, Slack, Gmail, ...) that don't have a bespoke tool binding of their own.
Routed through the MCP Gateway like every other tool binding here (single
governed egress), the same as memory/knowledge/etc. — NOT a direct
HTTP client to connector-host itself. connector-host scopes access via JWT
scopes (connector:invoke plus a per-connector/operation scope) and is
disabled by default per environment; going through the Gateway means the
agent never needs to hold or mint that token itself.
Uses the plain (non-retrying) :meth:_BaseToolBinding._call_tool, matching
every tool binding here except knowledge — connector operations are
frequently non-idempotent (e.g. "create-task", "send-message"), so this
class does not default to automatic retries the way KnowledgeTool's
read/idempotent-ish operations do. Callers who know a specific operation is
safe to retry should implement that themselves.
The MCP tool name and argument shape below are not a free choice — they
must match how connector-host's catalog registers tools (confirmed by
reading the catalog builder and Gateway protocol code directly): the
registered tool name is
novaos.connector.{connector_id}.{operation} for the standard tier, and
one segment longer for the other two tiers connector-host's catalog
recognizes — novaos.connector.licensed.{connector_id}.{operation} and
novaos.connector.ext.{connector_id}.{operation} — selected via the
tier argument (see :meth:call; cross-reference the platform's own
catalog builder, which must stay in lockstep with
:func:_connector_fqn below). The MCP Gateway does the actual tier
routing and scope enforcement server-side — this class only builds the
right tool name; a caller lacking the licensed/ext scope still correctly
gets a scope error back from the Gateway. This is independent of
connector-host's separate data_tier/max_tier entitlement gate
(raw/information/knowledge), which is enforced server-side
regardless of which MCP tool name was called.
call(connector_id: str, operation: str, params: dict[str, Any] | None = None, *, tier: Literal['standard', 'licensed', 'ext'] = 'standard', params_schema: dict[str, Any] | None = None, timeout_s: int | None = None) -> ToolResponse
async
Invoke operation on connector_id with params.
Calls the MCP tool novaos.connector.{connector_id}.{operation} (or,
for tier="licensed"/tier="ext", the same name with that tier
inserted as an extra segment — see :func:_connector_fqn) with
params as its arguments — the MCP Gateway forwards them verbatim as
the body of connector-host's own
POST /api/v1/connectors/{connector_id}/{operation}. On success,
resp.data mirrors connector-host's response envelope:
{"data": ..., "provenance": {...}, "meta": {...}}.
tier defaults to "standard", so every existing caller is
unaffected. Pass tier="licensed" or tier="ext" for a connector
registered on one of those tiers; an invalid tier value fails
client-side with no network call. The Gateway — not this SDK —
enforces whether the caller's token actually carries the scope for a
licensed/ext connector.
A connector-host environment that's disabled (CONNECTOR_HOST_ENABLED=false)
surfaces as a failure whose resp.error names SERVICE_DISABLED —
check for that specifically if you need to distinguish "not enabled in
this environment" from a genuine connector failure. A standard-tier
call that fails tool-not-found gets its error message augmented with a
hint to try tier="licensed"/tier="ext", since that is the most
common reason a connector-host tool name isn't found.
Before any network call, params is checked for JSON-serializability
and capped at :attr:MAX_PARAMS_BYTES — this SDK has no per-connector
schema to validate structure against, so it can only guard against
gross caller mistakes, not malformed-but-small payloads. If you do
know the expected shape for this connector_id/operation pair,
pass it as params_schema (a JSON Schema) to validate params
against it client-side, via the same :func:validate_against_schema
used for Ex10 structured extraction — this requires the optional
[schema] extra only if you actually pass params_schema.
.. admonition:: params is recorded unmasked in the eval transcript, if one is active
This method calls :meth:_BaseToolBinding._call_tool, which
automatically records every call — params and the response
data — into the active EvaluationContext via _record_eval,
with no masking and no way to opt out short of not having an
active evaluation context. Connector params routinely carry
customer data (a ClickUp task body, an email address). This is
unrelated to AgentBuilder(mask_output_pii=True), which only
masks the outbound A2A task, not the eval transcript. See
:class:novaos_client_sdk.evaluation.models.ToolCall for the full
default posture.
FilesTool(mcp_gateway_url: str, token: str, tenant_id: str, *, default_timeout_s: int | None = None, gateway_timeout_s: int | None = None)
Bases: _BaseToolBinding
Scoped file storage.
HITLTool(mcp_gateway_url: str, token: str, tenant_id: str, *, default_timeout_s: int | None = None, gateway_timeout_s: int | None = None)
Bases: _BaseToolBinding
Human-in-the-loop approval requests.
request_input(prompt: str, input_type: str = 'text') -> ToolResponse
async
Unavailable — no hitl-service backend exists.
Fails closed without calling the gateway, matching the platform's agent-sdk behavior for the same tool.
requires_write_hitl(tool_fqn: str) -> bool
Local, synchronous classification of whether a write-class MCP
tool call is likely to hit Gate A's enforceMcpHitl (-32042).
No network call — this calls the exact same mcpToolActionClass
classifier Gate A itself uses (mirrored from the
platform's own implementation), so it cannot drift from the
platform's actual enforcement decision the way
a separately-maintained mint-time hint could. Not a guarantee (OPA
policy can still override per tenant), but lets a skill decide up
front whether to build a pause before calling tool_fqn, instead
of guessing or hand-rolling a fail-then-recover loop. Mirrors
HITLTool.requiresWriteHitl in the TypeScript agent SDK.
HTTPTool(mcp_gateway_url: str, token: str, tenant_id: str, *, default_timeout_s: int | None = None, gateway_timeout_s: int | None = None)
Bases: _BaseToolBinding
Governed outbound HTTP client (domain-allowlisted).
IngestItem(content: str, metadata: dict[str, Any] | None = None)
dataclass
A single document to submit in a batch ingestion request.
Attributes:
| Name | Type | Description |
|---|---|---|
content |
str
|
Raw document text to ingest. |
metadata |
dict[str, Any] | None
|
Optional key-value metadata passed through to the backend
(e.g. |
IngestJobStatus(index: int, job_id: str | None, status: str, error: str | None = None, duplicate_of: str | None = None)
dataclass
Per-item status within a :class:BatchIngestResponse.
Attributes:
| Name | Type | Description |
|---|---|---|
index |
int
|
Zero-based position of this item in the original
:meth: |
job_id |
str | None
|
Backend job UUID, or |
status |
str
|
One of |
error |
str | None
|
Human-readable error message when status is |
duplicate_of |
str | None
|
Id of the existing document when the upload was a
duplicate of content already indexed (the job itself finishes as
|
is_duplicate |
bool
|
|
KnowledgeTool(mcp_gateway_url: str, token: str, tenant_id: str, *, max_retries: int = DEFAULT_MAX_RETRIES, base_delay_s: float = DEFAULT_BASE_DELAY_S, circuit_breaker_threshold: int = 5, circuit_breaker_reset_s: float = 30.0, default_timeout_s: int | None = None, gateway_timeout_s: int | None = None)
Bases: _BaseToolBinding
RAG search and document ingestion.
All network operations use exponential backoff with jitter and a shared circuit breaker so transient MCP Gateway failures are handled automatically.
Resilience defaults:
- Max retries: 3 (initial attempt + 2 retries)
- Base backoff: 1.0 s → 2.0 s → 4.0 s (±50 % jitter applied)
- Retryable statuses: HTTP 429, 502, 503, 504 and network timeouts
- Circuit breaker: trips after 5 consecutive failures, resets after 30 s
- Timeout:
default_timeout_s(128 s by default, see docs/timeout-hierarchy.md) for search / status / graph operations; 120 s for ingest submissions specifically
These defaults match the MemoryClient retry policy and can be customised
via constructor arguments.
Initialise KnowledgeTool with resilience settings.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
mcp_gateway_url
|
str
|
MCP Gateway base URL. |
required |
token
|
str
|
Bearer token for the |
required |
tenant_id
|
str
|
Tenant UUID for the |
required |
max_retries
|
int
|
Maximum number of attempts per operation (default 3). |
DEFAULT_MAX_RETRIES
|
base_delay_s
|
float
|
Base delay for exponential backoff (default 1.0 s). |
DEFAULT_BASE_DELAY_S
|
circuit_breaker_threshold
|
int
|
Consecutive failures before the circuit opens (default 5). |
5
|
circuit_breaker_reset_s
|
float
|
Seconds before a half-open probe is allowed after the circuit opens (default 30.0). |
30.0
|
default_timeout_s
|
int | None
|
Default per-call timeout in seconds. Defaults to
|
None
|
gateway_timeout_s
|
int | None
|
Optional opt-in validation floor — raises
|
None
|
build_graph(collection_id: str, *, timeout_s: int | None = None) -> ToolResponse
async
Kick off (or re-trigger) GraphRAG indexing for a collection.
Fire-and-forget: the backend flips the collection's graph status to
"pending" and returns immediately — unlike :meth:ingest, there is
no job_id to poll. Use :meth:get_graph_status (or
:meth:poll_graph_build_until_complete) on the same collection_id
to observe the pending -> running -> completed|failed transition.
Returns a :class:ToolResponse whose data mirrors the backend's
immediate ack (status, entity_count, relationship_count,
community_count — all zero at this point, since building hasn't
started yet).
get_graph_status(collection_id: str, *, timeout_s: int | None = None) -> ToolResponse
async
Retrieve the current GraphRAG build status for a collection.
Returns a :class:ToolResponse whose data contains
graph_status (one of "none", "pending", "running",
"completed", "failed"), graph_error, and aggregate counts
(graph_entity_count, graph_relationship_count,
graph_community_count).
get_ingest_status(job_id: str) -> IngestJobStatus
async
Return the current status of a single ingestion job as a typed object.
Typed convenience wrapper around :meth:get_job_status. Prefer
:meth:get_job_status when you need the raw :class:ToolResponse
envelope; use this method when you want a concrete
:class:IngestJobStatus for programmatic status checks.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
job_id
|
str
|
The job UUID returned by :meth: |
required |
Returns:
| Type | Description |
|---|---|
IngestJobStatus
|
class: |
IngestJobStatus
|
|
IngestJobStatus
|
queries; use :class: |
get_job_status(job_id: str, *, timeout_s: int | None = None) -> ToolResponse
async
Retrieve the current status of an ingestion job.
Returns a :class:ToolResponse whose data contains at minimum
status (one of "pending", "running", "completed",
"failed", "dead_letter", "cancelled") and optionally error on failure.
When the upload duplicated content that was already indexed, the job
is "completed" and duplicate_of holds the original document's
id (also the job's document_id).
Uses this binding's default_timeout_s (128s by default) unless
timeout_s overrides it — status polls should normally be fast. The
RAG MCP content envelope is unwrapped so callers can access
resp.data["status"] directly.
Retries on 5xx / timeout with exponential backoff.
ingest(content: str, metadata: dict | None = None, *, timeout_s: int | None = None) -> ToolResponse
async
Submit a document for ingestion.
Returns a :class:ToolResponse whose data contains job_id —
the async job identifier you can pass to :meth:get_job_status or
:meth:poll_until_complete.
Defaults to a 120-second timeout because ingestion is an async
operation whose HTTP acknowledgement may itself take a few seconds;
pass timeout_s to override for larger documents. The RAG MCP
content envelope is unwrapped so callers can access
resp.data["job_id"] directly.
Retries on 5xx / timeout (not 4xx) with exponential backoff.
ingest_batch(items: list[IngestItem], *, collection: str | None = None) -> BatchIngestResponse
async
Submit multiple documents for ingestion with bounded concurrency.
Items are submitted with up to :attr:BATCH_CONCURRENCY_LIMIT (10)
concurrent requests to avoid overwhelming the MCP Gateway. Each
receives its own backend job_id. Items that fail to submit
(network error, content too large, etc.) are recorded with
status="submit_error" — the other items continue unaffected.
The returned :class:BatchIngestResponse mirrors the input order.
Pass it to :meth:poll_batch_until_complete to await all jobs, or
iterate resp.items and call :meth:poll_until_complete per
job_id for finer-grained control.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
items
|
list[IngestItem]
|
Documents to ingest. |
required |
collection
|
str | None
|
Optional collection name / ID passed into each
item's metadata (overridden by any |
None
|
Returns:
| Type | Description |
|---|---|
BatchIngestResponse
|
class: |
BatchIngestResponse
|
entries in input order. |
poll_batch_until_complete(batch: BatchIngestResponse, *, poll_interval_s: float = 2.0, max_wait_s: float = 600.0) -> BatchIngestResponse
async
Poll all jobs in a batch until every job reaches a terminal state.
Polls successfully-submitted jobs (job_id is not None) in
parallel on each tick. Items with status="submit_error" are
already terminal and are preserved unchanged.
Returns an updated :class:BatchIngestResponse with final per-item
statuses in input order. Raises :class:TimeoutError if
max_wait_s elapses before all jobs complete.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
batch
|
BatchIngestResponse
|
Response from a prior :meth: |
required |
poll_interval_s
|
float
|
Seconds to wait between polling rounds. |
2.0
|
max_wait_s
|
float
|
Maximum total wait before :class: |
600.0
|
poll_graph_build_until_complete(collection_id: str, *, poll_interval_s: float = 2.0, max_wait_s: float = 300.0) -> ToolResponse
async
Block (async) until a :meth:build_graph run reaches a terminal state.
Unlike :meth:poll_until_complete (which polls by job_id), there is
no job identifier for a graph build — this re-polls
:meth:get_graph_status on the same collection_id instead.
Terminal states: "completed", "failed".
Returns the final :class:ToolResponse from :meth:get_graph_status.
Raises :class:TimeoutError if max_wait_s elapses without a
terminal status.
poll_until_complete(job_id: str, *, poll_interval_s: float = 2.0, max_wait_s: float = 300.0) -> ToolResponse
async
Block (async) until the ingestion job reaches a terminal state.
Terminal states: "completed", "failed", "dead_letter",
"cancelled" (the job's collection was deleted before it finished).
A duplicate upload is a "completed" job whose data carries
duplicate_of (the original document's id); check that field rather
than a status.
Returns the final :class:ToolResponse from :meth:get_job_status
(data is already unwrapped — access resp.data["status"]
directly). Raises :class:TimeoutError if max_wait_s elapses
without a terminal status.
query_graph_global(question: str, collection_id: str, *, timeout_s: int | None = None) -> ToolResponse
async
Theme/community-report synthesis across the graph ("global" search mode).
Same preconditions and response shape as :meth:query_graph_local,
except metadata.search_mode == "graph_global". Use this for
broad, corpus-wide questions ("what are the main themes...") rather
than entity-specific lookups.
query_graph_local(question: str, collection_id: str, *, timeout_s: int | None = None) -> ToolResponse
async
Entity-centric, multi-hop graph search ("local" search mode).
Requires the graph to already be built — check
:meth:get_graph_status shows graph_status == "completed" first.
If the graph isn't ready, the backend returns a degraded response
(resp.data["degraded"] is True) prompting a :meth:build_graph
call rather than an error.
Returns a :class:ToolResponse whose data contains answer,
citations, confidence, degraded, and
metadata.search_mode == "graph_local".
MemoryTool(mcp_gateway_url: str, token: str, tenant_id: str, *, default_timeout_s: int | None = None, gateway_timeout_s: int | None = None)
Bases: _BaseToolBinding
Governed door to Memory Service: all six novaos.memory.* tools.
Every field Memory Service accepts on the agent door is reachable from here;
tests/test_memory_contract.py fails if the service schema and these
signatures drift apart.
query(scope: str, query: str, top_k: int = 10, filters: dict[str, Any] | None = None, *, memory_type: MemoryType | None = None, conversation_id: str | None = None, token_budget: int | None = None, latency_budget_ms: int | None = None, timeout_s: int | None = None) -> ToolResponse
async
Query memories. Optional memory_type and conversation_id narrow
durable recall inside the search (shortcuts for the same keys in
filters); score is null for run/task items.
update(scope: str, memory_id: str, version: int, content: str | None = None, metadata: dict[str, Any] | None = None, *, timeout_s: int | None = None) -> ToolResponse
async
Update one of the agent's own agent-scope memories in place.
version is the item's version from a prior query; a stale one
is rejected (409). metadata may carry tags, confidence and
custom. At least one of content or metadata is required.
write(scope: str, content: str, metadata: dict[str, Any] | None = None, *, memory_type: MemoryType | None = None, ttl_seconds: int | None = None, classification: str | None = None, timeout_s: int | None = None) -> ToolResponse
async
Write a memory. memory_type (semantic, episodic or
procedural) is optional; omitted, nothing extra is stored.
NotifyTool(mcp_gateway_url: str, token: str, tenant_id: str, *, default_timeout_s: int | None = None, gateway_timeout_s: int | None = None)
Bases: _BaseToolBinding
Multi-channel notifications.
ToolBindings(mcp_gateway_url: str, token: str, tenant_id: str, *, default_timeout_s: int | None = None, gateway_timeout_s: int | None = None)
Container for all platform tool bindings. Access via ctx.tools.knowledge, ctx.tools.agents, etc.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
mcp_gateway_url
|
str
|
MCP Gateway base URL. |
required |
token
|
str
|
Bearer token for the |
required |
tenant_id
|
str
|
Tenant UUID for the |
required |
default_timeout_s
|
int | None
|
Default per-tool-call timeout in seconds,
applied to every binding unless a call overrides it with its
own |
None
|
gateway_timeout_s
|
int | None
|
Optional opt-in validation floor applied to
every binding — raises |
None
|
set_custom_headers(custom_headers: Mapping[str, str]) -> None
Forward the caller's custom_headers on every tool call of every binding.
Called once per request by AgentExecutionContext. Names are filtered
again here and an SDK-set header such as Authorization always wins.
set_event_sink(sink: Callable[[str, dict[str, Any]], None] | None) -> None
Wire every binding's tool calls into a live event stream.
Called by :class:~novaos_client_sdk.agent_context.AgentExecutionContext
for a SendStreamingMessage request, so ctx.tools.knowledge.search(...)
and its siblings emit tool.call / tool.result as the skill runs.
None (the default every non-streaming request keeps) turns emission
back off — see :meth:_BaseToolBinding._emit_event.
set_run_id(run_id: str) -> None
Set the current run's id on all tool bindings.
Called once per request by AgentExecutionContext so every tool-call
span this binding creates carries novaos.run_id as an attribute,
matching the run in the platform's own run records.
set_traceparent(traceparent: str) -> None
Set a per-request W3C traceparent on all tool bindings (P4-fleet F16).
In fleet mode the same pod handles sequential runs, so TRACEPARENT env
var is stale after the first run. Call this once per request with the
inbound traceparent header value so all outbound MCP calls carry the
correct trace context for the current run.
Calling agents
novaos_client_sdk.client
Client for calling agents over the A2A protocol (spec v1.0.1).
This module used to wrap
BeeAI's acp_sdk.client.Client and expose run_sync / run_stream; it
now speaks A2A v1.0 JSON-RPC 2.0 directly, through
:class:novaos_client_sdk._a2a.A2ATransport.
run_sync and run_stream are gone outright — there are no aliases.
Per the migration's scope decision (2026-08-26, Ashu Gupta, CAIO) NovaOS does not
stay backward compatible with ACP: no dual-protocol serving, no coexistence
period, no deprecated-but-functional names. The replacements are not one-for-one
renames either, because A2A's model is task-centric where ACP's was run-centric:
===================== ================================================
ACP A2A
===================== ================================================
run_sync :meth:Client.send_message
run_stream :meth:Client.send_streaming_message
run_safe :meth:Client.send_message_safe
(no equivalent) :meth:Client.get_task
(no equivalent) :meth:Client.subscribe_to_task
(no equivalent) :meth:Client.cancel_task
(no equivalent) :meth:Client.list_tasks
===================== ================================================
The four bottom rows are the capability A2A adds: an ACP run was fire-and-read,
with no way to re-query it, reconnect to it, stop it, or enumerate what an agent
is working on. list_tasks in particular was missing from the migration's
original method list entirely until ADR finding F13 caught it.
There is no agent= parameter any more, and that is the protocol, not a
simplification. ACP's POST /runs carried an agent name because one ACP
server fronted many agents. A2A has no such field on any operation: an agent
is a URL, advertised by its own Agent Card at
/.well-known/agent-card.json. So a :class:Client addresses one agent, and
calling a different one means constructing a different client. Which skill
within that agent runs is a separate question, answered by skill_id — see
:meth:Client.send_message.
Return types are A2A's, not a NovaOS envelope. run_sync returned a
Run; send_message returns the :class:~novaos_client_sdk.a2a_task.Task
the agent created (or, in the case the spec allows, a bare
:class:~novaos_client_sdk.a2a_types.A2AMessage). Run was ACP's result
envelope and had no producer left once run_sync went, so it was removed
rather than left as a shape nothing fills — a Task carries everything it did
(artifacts, a status) plus the id the four new methods need.
Client(base_url: str, timeout: float | None = None, headers: dict[str, str] | None = None, auth: AuthProvider | None = None, extensions: Iterable[A2AExtension] | None = None, *, card: AgentCard | None = None, config: ClientConfig | None = None, tenant: str | None = None, interceptors: list[ClientCallInterceptor] | None = None, protocol_binding: str = PROTOCOL_BINDING_JSONRPC, guard_connect_ips: bool = False, allow_private_ips: bool = False)
Client for calling one A2A agent.
The client is an async context manager; the HTTP connection is opened on entry and reused for every call made inside it.
Example
Initialize the Client.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
base_url
|
str
|
Base URL of the A2A agent (e.g., |
required |
timeout
|
float | None
|
Request timeout in seconds. Applied to the non-streaming
calls only — see :meth: |
None
|
headers
|
dict[str, str] | None
|
Additional HTTP headers to include in requests. |
None
|
auth
|
AuthProvider | None
|
Authentication provider for requests. |
None
|
extensions
|
Iterable[A2AExtension] | None
|
A2A protocol extensions to activate on every call.
Their URIs travel in the |
None
|
card
|
AgentCard | None
|
The agent's :class: |
None
|
config
|
ClientConfig | None
|
Per-client defaults layered under each call's own keyword
arguments. |
None
|
tenant
|
str | None
|
Explicit |
None
|
interceptors
|
list[ClientCallInterceptor] | None
|
Cross-cutting behaviour run around
every call, in list order, through :meth: |
None
|
protocol_binding
|
str
|
Which A2A binding this client speaks at
|
PROTOCOL_BINDING_JSONRPC
|
guard_connect_ips
|
bool
|
Resolve the host again at connect time, refuse a
private/loopback/link-local/reserved address, and connect to the
address that was checked. Closes the gap between
:func: |
False
|
allow_private_ips
|
bool
|
With |
False
|
Raises:
| Type | Description |
|---|---|
ValueError
|
If two extensions claim the same URI, or one declares no URI at all. |
auth: AuthProvider | None
property
Get the authentication provider.
base_url: str
property
Get the base URL of the A2A agent.
card: AgentCard | None
property
The agent's Agent Card, if this client was constructed with one.
None for a client built from nothing but a URL — discovery is a
convenience this client can do without, never a precondition.
config: ClientConfig | None
property
This client's ClientConfig, if it was constructed with one.
extensions: ExtensionRegistry
property
The A2A protocol extensions this client activates on every call.
headers: dict[str, str] | None
property
Get additional HTTP headers.
interceptors: list[ClientCallInterceptor]
property
The interceptors this client runs around every call, in call order.
A copy: mutating the returned list does not change what this client
runs, the same way :attr:headers returns a copy rather than the
live dict.
protocol_binding: str
property
Which A2A binding this client speaks at :attr:base_url.
tenant: str | None
property
The tenant sent on every request, or None if none applies.
Either the explicit tenant= constructor argument, or — failing
that — the tenant declared by the card's selected interface. See
:meth:__init__.
timeout: float | None
property
Get the request timeout in seconds.
cancel_task(task_id: str, *, metadata: dict[str, Any] | None = None, context: ClientCallContext | None = None) -> Task
async
Ask the agent to stop a task (A2A CancelTask).
No ACP equivalent — an ACP run could not be stopped once dispatched.
Cancellation is a request: the agent decides. The returned task shows
what it decided, so a caller reads task.status.state rather than
assuming TASK_STATE_CANCELED. A task already in a terminal state
cannot be canceled and answers -32002
(:class:~novaos_client_sdk.exceptions.TaskNotCancelableError).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
task_id
|
str
|
The task's A2A id. |
required |
metadata
|
dict[str, Any] | None
|
Optional metadata to carry with the request. |
None
|
context
|
ClientCallContext | None
|
As :meth: |
None
|
Returns:
| Type | Description |
|---|---|
Task
|
The task as it stands after the agent handled the request. |
Raises:
| Type | Description |
|---|---|
TaskNotFoundError
|
If the agent does not know this task id. |
TaskNotCancelableError
|
If the task is already terminal. |
NovaOSError
|
If the client is not initialized, or the call fails below the protocol layer. |
continue_with_hitl_response(hitl_response: Any, *, task_id: str | None = None, content: str | None = None, skill_id: str | None = None, context: ClientCallContext | None = None) -> Task | A2AMessage
async
Continue a task with a HITL response object.
Convenience wrapper over :meth:send_hitl_response.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
hitl_response
|
Any
|
HITLResponse object. |
required |
task_id
|
str | None
|
The waiting task's id — see :meth: |
None
|
content
|
str | None
|
Optional human-readable content for the message. |
None
|
skill_id
|
str | None
|
Optional skill selector. |
None
|
context
|
ClientCallContext | None
|
As :meth: |
None
|
Returns:
| Type | Description |
|---|---|
Task | A2AMessage
|
The task as it stands after the agent processed the response. |
create_task_push_notification_config(config: TaskPushNotificationConfig, *, context: ClientCallContext | None = None) -> TaskPushNotificationConfig
async
Register (or replace) a webhook for one task's status changes.
A2A CreateTaskPushNotificationConfig. Check
card.capabilities.push_notifications before calling — an agent
that does not support push notification config answers -32003.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
config
|
TaskPushNotificationConfig
|
The registration to create. |
required |
context
|
ClientCallContext | None
|
As :meth: |
None
|
Returns:
| Type | Description |
|---|---|
TaskPushNotificationConfig
|
The registration as the agent stored it — read |
TaskPushNotificationConfig
|
if the caller left it empty, since that is the only way to learn |
TaskPushNotificationConfig
|
what the agent assigned. |
Raises:
| Type | Description |
|---|---|
PushNotificationNotSupportedError
|
If the agent does not support
push notification config (A2A |
TaskNotFoundError
|
If the agent does not know |
NovaOSError
|
If the client is not initialized, or the call fails below the protocol layer. |
delete_task_push_notification_config(task_id: str, config_id: str, *, context: ClientCallContext | None = None) -> None
async
Remove one push notification registration (A2A DeleteTaskPushNotificationConfig).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
task_id
|
str
|
The task the registration was created against. |
required |
config_id
|
str
|
The registration's own id. |
required |
context
|
ClientCallContext | None
|
As :meth: |
None
|
Raises:
| Type | Description |
|---|---|
PushNotificationNotSupportedError
|
If the agent does not support
push notification config (A2A |
TaskNotFoundError
|
If the agent does not know this task id, or this config id. |
NovaOSError
|
If the client is not initialized, or the call fails below the protocol layer. |
extract_error_messages(task: Task) -> list[Any]
Extract all error messages from a task.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
task
|
Task
|
Task returned by any operation on this client. |
required |
Returns:
| Type | Description |
|---|---|
list[Any]
|
List of ErrorMessage objects found in the task. |
extract_hitl_messages(task: Task) -> list[Any]
Extract all HITL messages from a task.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
task
|
Task
|
Task returned by any operation on this client. |
required |
Returns:
| Type | Description |
|---|---|
list[Any]
|
List of HITLMessage objects found in the task's artifacts and |
list[Any]
|
status message. |
extract_progress_from_stream(stream: AsyncIterator[StreamFrame], on_progress: Callable[[Any], None] | None = None) -> AsyncIterator[StreamFrame]
async
Pass a stream through, invoking on_progress for each progress message.
Both frame kinds that can carry one are inspected: an agent may report progress as a status update's message or as an artifact chunk, and which it chooses is its business rather than the caller's.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
stream
|
AsyncIterator[StreamFrame]
|
The iterator returned by :meth: |
required |
on_progress
|
Callable[[Any], None] | None
|
Called with each ProgressMessage found. |
None
|
Yields:
| Type | Description |
|---|---|
AsyncIterator[StreamFrame]
|
Every frame, unchanged. |
Example
from novaos_client_sdk.a2a_events import TaskArtifactUpdateEvent
async for frame in client.extract_progress_from_stream(
client.send_streaming_message("Go", skill_id="research"),
on_progress=lambda p: print(f"Progress: {p.progress}"),
):
if isinstance(frame, TaskArtifactUpdateEvent):
print(frame.artifact.text)
extract_progress_messages(task: Task) -> list[Any]
Extract all progress messages from a task.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
task
|
Task
|
Task returned by any operation on this client. |
required |
Returns:
| Type | Description |
|---|---|
list[Any]
|
List of ProgressMessage objects found in the task. |
extract_session_id(hitl_message: Any) -> str | None
Extract session_id from a HITL message.
Helper method to get session_id for HITL response correlation. A NovaOS
session id is what A2A calls a contextId, so the value this returns
is what :meth:send_message's context_id takes.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
hitl_message
|
Any
|
HITLMessage object |
required |
Returns:
| Type | Description |
|---|---|
str | None
|
Session ID if present, None otherwise |
extract_standardized_messages(task: Task) -> dict[str, list[Any]]
Extract all standardized NovaOS messages from a task.
MessageParser takes an artifact or a message unchanged — it
dispatches on .parts, which both have, and NovaOS metadata is a
tagged DataPart in either.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
task
|
Task
|
Task returned by :meth: |
required |
Returns:
| Type | Description |
|---|---|
dict[str, list[Any]]
|
Dictionary with keys |
dict[str, list[Any]]
|
|
get_task(task_id: str, *, history_length: int | None = None, context: ClientCallContext | None = None) -> Task
async
Fetch one authoritative snapshot of a task (A2A GetTask).
No ACP equivalent: an ACP run could not be re-queried once its response had been read.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
task_id
|
str
|
The task's A2A id. |
required |
history_length
|
int | None
|
Hint for how many history messages to include. A
hint only — see :meth: |
None
|
context
|
ClientCallContext | None
|
As :meth: |
None
|
Returns:
| Type | Description |
|---|---|
Task
|
The task as the agent currently holds it. |
Raises:
| Type | Description |
|---|---|
TaskNotFoundError
|
If the agent does not know this task id
(A2A |
NovaOSError
|
If the client is not initialized, or the call fails below the protocol layer. |
get_task_push_notification_config(task_id: str, config_id: str, *, context: ClientCallContext | None = None) -> TaskPushNotificationConfig
async
Fetch one push notification registration (A2A GetTaskPushNotificationConfig).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
task_id
|
str
|
The task the registration was created against. |
required |
config_id
|
str
|
The registration's own id, from
:meth: |
required |
context
|
ClientCallContext | None
|
As :meth: |
None
|
Returns:
| Type | Description |
|---|---|
TaskPushNotificationConfig
|
The registration as the agent currently holds it. |
Raises:
| Type | Description |
|---|---|
PushNotificationNotSupportedError
|
If the agent does not support
push notification config (A2A |
TaskNotFoundError
|
If the agent does not know this task id, or this config id. |
NovaOSError
|
If the client is not initialized, or the call fails below the protocol layer. |
list_task_push_notification_configs(task_id: str, *, page_size: int | None = None, page_token: str | None = None, context: ClientCallContext | None = None) -> TaskPushNotificationConfigList
async
Enumerate one task's push notification registrations.
A2A ListTaskPushNotificationConfigs — plural, since v1.0
pluralised it from v0.3's singular name — a task may carry more
than one registration (§3.1.7). Paginates the same way
:meth:list_tasks does: a non-empty next_page_token means there
is more, fetched by passing it back as page_token.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
task_id
|
str
|
The task to enumerate registrations for. |
required |
page_size
|
int | None
|
Maximum registrations per page. |
None
|
page_token
|
str | None
|
Continue a previous page, using the
|
None
|
context
|
ClientCallContext | None
|
As :meth: |
None
|
Returns:
| Type | Description |
|---|---|
TaskPushNotificationConfigList
|
One page, as a |
TaskPushNotificationConfigList
|
class: |
Raises:
| Type | Description |
|---|---|
PushNotificationNotSupportedError
|
If the agent does not support
push notification config (A2A |
TaskNotFoundError
|
If the agent does not know this task id. |
NovaOSError
|
If the client is not initialized, or the call fails below the protocol layer. |
list_tasks(*, context_id: str | None = None, status: TaskState | str | None = None, page_size: int | None = None, page_token: str | None = None, history_length: int | None = None, status_timestamp_after: str | None = None, include_artifacts: bool | None = None, context: ClientCallContext | None = None) -> TaskList
async
Enumerate the agent's tasks (A2A ListTasks).
This operation has no ACP counterpart at all — ACP offered no way to ask an agent what it was working on — and it was missing from the migration's own client-method list until ADR finding F13 caught it.
Every filter is optional, and every one is a request: the spec lets an agent implement as much or as little of the filtering as it can, so a caller must not assume the returned page is exactly the set it asked for.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
context_id
|
str | None
|
Only tasks belonging to this A2A conversation. |
None
|
status
|
TaskState | str | None
|
Only tasks in this state. Accepts a
:class: |
None
|
page_size
|
int | None
|
Maximum tasks per page. |
None
|
page_token
|
str | None
|
Continue a previous page, using the |
None
|
history_length
|
int | None
|
Hint for how much history to include per task. |
None
|
status_timestamp_after
|
str | None
|
Only tasks whose status changed after this RFC 3339 timestamp. |
None
|
include_artifacts
|
bool | None
|
Ask for each task's artifacts. Off by default at most agents, since a listing is usually a lightweight overview. |
None
|
context
|
ClientCallContext | None
|
As :meth: |
None
|
Returns:
| Type | Description |
|---|---|
TaskList
|
One page, as a :class: |
TaskList
|
non-empty |
Raises:
| Type | Description |
|---|---|
UnsupportedOperationError
|
If the agent does not implement the
method (A2A |
NovaOSError
|
If the client is not initialized, or the call fails below the protocol layer. |
send_hitl_response(action_id: str, session_id: str, *, task_id: str | None = None, input_value: str | None = None, content: str | None = None, metadata: dict[str, Any] | None = None, skill_id: str | None = None, context: ClientCallContext | None = None) -> Task | A2AMessage
async
Send a HITL response back to the agent.
Pass task_id when answering a task that is waiting. Under ACP a
HITL reply was simply another run, correlated only by the NovaOS session
id inside its payload. A2A has a first-class notion of continuing a
task — one sitting in TASK_STATE_INPUT_REQUIRED resumes when it
receives a message naming it — so the reply should carry the id of the
task it answers. Without one, a conformant agent has no protocol-level
way to know which paused task this belongs to.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
action_id
|
str
|
Action ID from the user's response. |
required |
session_id
|
str
|
Session ID from the original HITL message; sent as the
A2A |
required |
task_id
|
str | None
|
The waiting task's id, from the |
None
|
input_value
|
str | None
|
Optional input value (for |
None
|
content
|
str | None
|
Optional human-readable content for the message. |
None
|
metadata
|
dict[str, Any] | None
|
Optional additional metadata. |
None
|
skill_id
|
str | None
|
Optional skill selector, if the agent needs one. |
None
|
context
|
ClientCallContext | None
|
As :meth: |
None
|
Returns:
| Type | Description |
|---|---|
Task | A2AMessage
|
The task as it stands after the agent processed the response. |
send_message(message: MessageInput, *, skill_id: str | None = None, context_id: str | None = None, task_id: str | None = None, metadata: dict[str, Any] | None = None, return_immediately: bool = False, history_length: int | None = None, accepted_output_modes: list[str] | None = None, task_push_notification_config: TaskPushNotificationConfig | None = None, context: ClientCallContext | None = None) -> Task | A2AMessage
async
Send a message to the agent (A2A SendMessage).
Replaces run_sync.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
message
|
MessageInput
|
The message to send — an
:class: |
required |
skill_id
|
str | None
|
Which of the agent's skills should run. A2A has no
dedicated field for this, so it travels as
|
None
|
context_id
|
str | None
|
A2A |
None
|
task_id
|
str | None
|
Continue an existing task rather than starting a new one —
how a caller answers a task sitting in
|
None
|
metadata
|
dict[str, Any] | None
|
Request-level metadata ( |
None
|
return_immediately
|
bool
|
A2A |
False
|
history_length
|
int | None
|
Hint for how many history messages to include in the returned task. A hint only: §3.7 lets an agent decline to keep history at all. |
None
|
accepted_output_modes
|
list[str] | None
|
MIME types this caller can render, sent as
|
None
|
task_push_notification_config
|
TaskPushNotificationConfig | None
|
Register (or replace) a webhook to
be notified of this task's status changes, sent as
|
None
|
context
|
ClientCallContext | None
|
This call's
:class: |
None
|
Returns:
| Name | Type | Description |
|---|---|---|
The |
Task | A2AMessage
|
class: |
an |
Task | A2AMessage
|
class: |
Task | A2AMessage
|
spec allows — an agent answering without creating a task. Both are |
|
Task | A2AMessage
|
part of the contract; |
|
Task | A2AMessage
|
apart, since that is what the |
Raises:
| Type | Description |
|---|---|
NovaOSError
|
If the client is not initialized. |
A2AProtocolError
|
If the agent replied with an A2A protocol error. |
InvalidAgentResponseError
|
If the response has neither a |
NovaOSConnectionError
|
If the connection to the agent fails. |
NovaOSAuthenticationError
|
If authentication fails. |
NovaOSAgentNotFoundError
|
If the agent URL answers 404. |
NovaOSValidationError
|
If request validation fails. |
NovaOSServerError
|
If the agent returns a server error. |
NovaOSRateLimitError
|
If the rate limit is exceeded. |
send_message_safe(message: MessageInput, *, default: Task | A2AMessage | None = None, **kwargs: Any) -> Task | A2AMessage | None
async
Send a message, returning default instead of raising for two errors.
Replaces run_safe. Only agent-not-found and connection failures are
swallowed — both mean "this agent was not reachable", which a caller
fanning out across several agents can reasonably shrug at. Everything
else, A2A protocol errors included, propagates: an agent that
answered -32001 was reached and had something to say, and hiding
that behind a default would turn a diagnosable failure into an empty
result.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
message
|
MessageInput
|
As :meth: |
required |
default
|
Task | A2AMessage | None
|
Value to return on the two handled errors. |
None
|
**kwargs
|
Any
|
Forwarded to :meth: |
{}
|
Returns:
| Type | Description |
|---|---|
Task | A2AMessage | None
|
The task or message, or default. |
send_streaming_message(message: MessageInput, *, skill_id: str | None = None, context_id: str | None = None, task_id: str | None = None, metadata: dict[str, Any] | None = None, history_length: int | None = None, accepted_output_modes: list[str] | None = None, task_push_notification_config: TaskPushNotificationConfig | None = None, context: ClientCallContext | None = None) -> AsyncIterator[StreamFrame]
async
Send a message and stream the agent's updates (A2A SendStreamingMessage).
Replaces run_stream. The frames are A2A's StreamResponse
members rather than ACP's RunYield: a
:class:~novaos_client_sdk.a2a_events.TaskStatusUpdateEvent, a
:class:~novaos_client_sdk.a2a_events.TaskArtifactUpdateEvent, a whole
:class:~novaos_client_sdk.a2a_task.Task, or a bare
:class:~novaos_client_sdk.a2a_types.A2AMessage.
Read the task state to know whether the run finished — not the fact
that iteration stopped. A2A v1.0 removed v0.3's final flag, so a
stream that ends without a terminal or interrupt state is a transport
failure wearing the shape of a completed one, which is the
misclassification this check exists to prevent.
:func:novaos_client_sdk.a2a_events.is_last_frame is the check.
This method does not always call SendStreamingMessage.
:meth:_should_stream_message decides first, and this method never
opens a stream when it says no:
self.config.streaming is False— the caller opted out.self.card.capabilities.streaming is False— the agent declared it does not support this operation.AgentBuilderimplementsSendStreamingMessagefor real (inhandle_a2a_streaming_request), so this now happens only for a pod whose card generator was told the streaming entry point is not wired in front of it — not for everyAgentBuilder-backed pod unconditionally.- A client with no card still streams. Discovery is optional, not
a precondition — see :meth:
__init__'scardparameter — so a missing card is not evidence the agent lacks the capability, and treating it as evidence would regress every caller who builds aClientdirectly from a URL.
When streaming is skipped, :meth:_send_streaming_message_fallback
calls :meth:send_message instead and yields its single result as one
frame, so the return type never changes — a caller that only ever
reads this method's iterator cannot tell the two paths apart except by
frame count. If it only got one frame carrying a terminal or interrupt
task, that is this fallback, not a run that produced nothing.
A card can still be wrong, or missing. An agent whose card claims
capabilities.streaming = true but does not actually route the
method — or one this client has no card for at all — answers -32004
(:class:~novaos_client_sdk.exceptions.UnsupportedOperationError) or,
if it does not route the method at all, -32601. Neither is a run
failure — fall back to :meth:send_message by hand in that case, the
same way this method's own proactive check does. A pod may send either
as an ordinary JSON body rather than as an SSE frame, which the
transport reads as an answer rather than as an empty stream.
The two codes land in different branches of the exception hierarchy
on purpose — -32004 is one of A2A's own errors and -32601 is
JSON-RPC's — so detect this condition by code rather than by type:
:func:~novaos_client_sdk.exceptions.jsonrpc_error_code reads it off
either, and
:data:~novaos_client_sdk.a2a_jsonrpc.METHOD_UNAVAILABLE_ERROR_CODES is
the set to test against.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
message
|
MessageInput
|
As :meth: |
required |
skill_id
|
str | None
|
As :meth: |
None
|
context_id
|
str | None
|
As :meth: |
None
|
task_id
|
str | None
|
As :meth: |
None
|
metadata
|
dict[str, Any] | None
|
As :meth: |
None
|
history_length
|
int | None
|
As :meth: |
None
|
accepted_output_modes
|
list[str] | None
|
As :meth: |
None
|
task_push_notification_config
|
TaskPushNotificationConfig | None
|
As :meth: |
None
|
context
|
ClientCallContext | None
|
As :meth: |
None
|
Yields:
| Name | Type | Description |
|---|---|---|
AsyncIterator[StreamFrame]
|
Each decoded stream frame — or, on the fallback path, exactly the |
|
frames |
AsyncIterator[StreamFrame]
|
meth: |
Raises:
| Type | Description |
|---|---|
NovaOSError
|
If the client is not initialized. |
A2AProtocolError
|
If the agent replied with one of A2A's own error codes, including one sent mid-stream. |
NovaOSValidationError
|
If the agent replied with a standard JSON-RPC
error such as |
NovaOSConnectionError
|
If the connection to the agent fails. |
subscribe_to_task(task_id: str, *, context: ClientCallContext | None = None) -> AsyncIterator[StreamFrame]
async
Reconnect to a running task's update stream (A2A SubscribeToTask).
The spec (§3.1.6) guarantees the first frame is the task's current state, which is what makes this usable as reconnect: a client that dropped its stream calls this and is told where the task stands, with no cursor to keep.
The snapshot is all that is guaranteed. A2A does not promise to replay messages or artifacts that arrived while the connection was down (§3.7); those can be permanently lost. That is a deliberate resolution, and it is why replacing a cursor-keyed poll loop with this is a simplification for status tracking specifically, not for content.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
task_id
|
str
|
The task's A2A id. |
required |
context
|
ClientCallContext | None
|
As :meth: |
None
|
Yields:
| Type | Description |
|---|---|
AsyncIterator[StreamFrame]
|
Each decoded stream frame, beginning with the current snapshot. |
Raises:
| Type | Description |
|---|---|
TaskNotFoundError
|
If the agent does not know this task id. |
UnsupportedOperationError
|
If the agent does not implement the
method — an |
NovaOSError
|
If the client is not initialized, or the call fails below the protocol layer. |
ClientConfig(streaming: bool = True, polling: bool = False, accepted_output_modes: list[str] = list(), push_notification_config: TaskPushNotificationConfig | None = None, supported_protocol_bindings: list[str] = list(), use_client_preference: bool = False, grpc_channel_factory: Callable[[str], Any] | None = None)
dataclass
Per-client defaults, layered underneath each call's own keyword arguments.
Mirrors the reference a2a-python ClientConfig (src/a2a/client/client.py),
trimmed to the fields this SDK consumes. httpx_client is not carried:
every transport here self-constructs its own httpx.AsyncClient /
grpc.aio.Channel on __aenter__ rather than requiring one be
injected (matching :class:~novaos_client_sdk._a2a.A2ATransport's
pre-existing shape), so there is nothing to inject. grpc_channel_factory,
supported_protocol_bindings and use_client_preference were left out
until the transports and selection logic that need them were built;
they now have been.
:meth:Client._apply_config is the seam these fields act through, rather
than each reaching into :meth:Client._send_params independently.
accepted_output_modes and push_notification_config are both read
through it; streaming and polling are read by the streaming
fallback, but not through this seam — see
:meth:Client._send_streaming_message_fallback. Tenant propagation
does not go through here either: a request's tenant is resolved
once, from the card and the tenant= constructor argument, not from a
per-call ClientConfig, so it is applied by
:meth:Client._apply_tenant instead.
accepted_output_modes: list[str] = field(default_factory=list)
class-attribute
instance-attribute
Output MIME types this client accepts by default, sent as
configuration.acceptedOutputModes unless a call overrides it with its
own accepted_output_modes=. Empty means "no preference stated", and an
empty list emits no key at all — see :meth:Client.send_message.
grpc_channel_factory: Callable[[str], Any] | None = None
class-attribute
instance-attribute
Builds the grpc.aio.Channel for a gRPC interface's target, for
custom credentials, interceptors, or channel options. None (the
default) is a sensible secure/insecure channel chosen from the target's
scheme — see
:meth:~novaos_client_sdk._a2a_grpc.GrpcTransport.__aenter__. Typed
Callable[[str], Any] rather than Callable[[str], grpc.aio.Channel]
so importing this module never requires the optional grpc extra to be
installed.
polling: bool = False
class-attribute
instance-attribute
Whether this client prefers to poll for task updates rather than hold the connection open.
push_notification_config: TaskPushNotificationConfig | None = None
class-attribute
instance-attribute
Push notification registration to apply by default, sent as
configuration.taskPushNotificationConfig unless a call overrides it
with its own task_push_notification_config=. None (the default)
means "no default" and emits no key — see :meth:Client.send_message.
Matches the reference a2a-python ClientConfig.push_notification_config
and the precedence its base_client.py applies: an explicit per-call
value, including an explicit None, is indistinguishable from "not
passed" on this field — there is no way to positionally say "explicitly no
config" the way an explicit [] says it for
:attr:accepted_output_modes — so a caller relying on the config default
for most calls and wanting to suppress it for one still sends its own
registration or a task without a config; there is no third state to ask
for "none, overriding the client's default".
streaming: bool = True
class-attribute
instance-attribute
Whether this client prefers a streaming response, where the agent's capabilities allow a choice.
supported_protocol_bindings: list[str] = field(default_factory=list)
class-attribute
instance-attribute
Protocol bindings this client is willing to speak, most preferred
first. Empty (the default) means JSON-RPC only —
:data:~novaos_client_sdk.a2a_agent_card.PROTOCOL_BINDING_JSONRPC — which
is exactly what this client spoke before the other two bindings were added,
so an unset config changes nothing. Consumed by
:func:~novaos_client_sdk.a2a_agent_card.select_interface, not by
Client directly — see
:meth:~novaos_client_sdk.discovery.AgentDiscoveryService.to_client.
use_client_preference: bool = False
class-attribute
instance-attribute
Whether binding selection prefers this client's ordering
(:attr:supported_protocol_bindings) over the agent card's own
(AgentCard.supported_interfaces order). False (the default) is
server preference: the first interface the card lists whose binding this
client also supports wins, so a caller with no opinion gets whatever the
agent recommends. Matches the reference a2a-python client's identical
flag and precedence.
novaos_client_sdk.gateway
Execution Gateway client for end-user applications.
Provides a client for driving agent execution through the NovaOS Execution Gateway.
This is intended for external / end-user applications (e.g., a marketplace frontend),
not for agents calling other agents — use :class:~novaos_client_sdk.client.Client,
which speaks A2A v1.0 JSON-RPC, for that.
The Execution Gateway exposes:
POST /api/v1/execute— Start a conversation / send a message to an agent.GET /api/v1/events/{runId}— Subscribe to real-time SSE token streaming.GET /api/v1/streaming/health— Check if real-time streaming is available.
Authentication uses NovaOS IAM tokens (OIDC Bearer tokens).
Why this module is not part of the A2A migration
An assessment of this surface during the ACP→A2A migration found it protocol-agnostic: it needs no A2A migration, and it never needed an ACP one.
Client addresses an agent directly and therefore speaks whatever wire
protocol that agent serves — which is why it was rewritten end-to-end as part
of that migration.
GatewayClient addresses the NovaOS platform, whose REST contract is
expressed entirely in platform nouns: an instance_id to run, a run_id to
follow, and a run lifecycle of QUEUED/RUNNING/WAITING_USER/
COMPLETED/FAILED/CANCELLED. None of those are ACP or A2A concepts.
The pod-facing protocol is chosen and spoken by the Temporal worker, several
hops below this client, and swapping it changes nothing on this wire.
The one thing the migration does reach is the payload of the hitl event:
the platform's human-in-the-loop pause is now backed by A2A's first-class
TASK_STATE_INPUT_REQUIRED rather than ACP's custom await_request field
(ADR F4). This client passes that payload through opaquely, so it is unaffected
either way.
Terminal events and KB-15
The one place the assessment did find a defect was terminal-event handling, and
it is the same bug class the migration exists to avoid repeating.
docs/knowledge-base/kb-15-marketplace-execution-stuck-acp-failed.md describes
a run that failed while the UI waited forever, because the failure arrived in a
shape the consumer did not recognize as an ending. :meth:GatewayClient.events
had exactly that shape: it stopped only on done and error, so a run whose
terminal signal arrives as status with a terminal payload.status — which
is how the platform records a failure to the event log, and therefore how every
replayed run reports its ending — neither ended the iteration nor surfaced the
failure.
:attr:StreamEvent.is_terminal and :attr:StreamEvent.is_failure are now the
contract, and :meth:GatewayClient.events stops on the former. Consumers should
branch on those properties rather than on event.type string equality, so a
future event type cannot reintroduce the same silence.
Example
from novaos_client_sdk.auth import BearerTokenAuth
from novaos_client_sdk.gateway import GatewayClient
from novaos_client_sdk.models import Message, MessagePart
auth = BearerTokenAuth(token=novaos_iam_token)
async with GatewayClient(
base_url="http://execution-gateway:8081",
auth=auth,
) as gw:
# Start execution
result = await gw.execute(instance_id="<uuid>", input_text="Hello!")
# Stream events
async for event in gw.events(result.run_id):
if event.type == "chunk":
print(event.content, end="", flush=True)
elif event.type == "done":
print()
break
elif event.type == "error":
print(f"Error: {event.content}")
break
Attachment(type: str, url: str | None = None, data: bytes | bytearray | memoryview | None = None, name: str | None = None)
dataclass
A file attached to an execution request (url or data).
Mirrors the Execution Gateway's Attachment schema. Exactly one of
url or data must be given; the fields are validated on construction,
so an invalid attachment raises before any request is sent.
Attributes:
| Name | Type | Description |
|---|---|---|
type |
str
|
MIME type or logical attachment type. |
url |
str | None
|
Location to fetch the file (an absolute URI). |
data |
bytes | bytearray | memoryview | None
|
Raw file content. Required if no |
name |
str | None
|
Plain filename; no path separators or traversal. |
Raises:
| Type | Description |
|---|---|
NovaOSError
|
If a field is invalid. |
to_dict() -> dict[str, str]
Serialize to the request body shape, omitting unset fields.
data is sent base64-encoded, as the gateway expects.
ExecuteResult(run_id: str, status: str | None = None, output: Any | None = None, raw: dict[str, Any] | None = None)
dataclass
Result of a POST /api/v1/execute call.
Attributes:
| Name | Type | Description |
|---|---|---|
run_id |
str
|
The execution run ID (used for SSE event subscription). |
status |
str | None
|
Execution status (e.g., |
output |
Any | None
|
Agent output data if execution completed synchronously. |
raw |
dict[str, Any] | None
|
The full response body for advanced inspection. |
from_dict(data: dict[str, Any]) -> ExecuteResult
classmethod
Create from the gateway response body.
GatewayClient(base_url: str, auth: AuthProvider | None = None, timeout: float | None = None, headers: dict[str, str] | None = None)
Async client for the NovaOS Execution Gateway.
Intended for end-user applications that drive agent execution through the platform's gateway layer (with NovaOS IAM authentication).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
base_url
|
str
|
Execution Gateway URL (e.g., |
required |
auth
|
AuthProvider | None
|
Authentication provider (typically |
None
|
timeout
|
float | None
|
Request timeout in seconds. |
None
|
headers
|
dict[str, str] | None
|
Additional HTTP headers. |
None
|
events(run_id: str, *, last_event_id: str | None = None) -> AsyncIterator[StreamEvent]
async
Subscribe to real-time SSE events for a running execution.
Connects to GET /api/v1/events/{runId} and yields events as they
arrive. Iteration ends once an event reports the run has finished — see
:attr:StreamEvent.is_terminal, which covers the status shape a
replayed or late-failing run ends in as well as the done/error
sentinels a live stream ends in.
The terminating event is always yielded before iteration stops, so a
caller sees how the run ended. Check :attr:StreamEvent.is_failure on
it: a run can end in failure without ever producing an error event,
and treating "the loop finished" as "the run succeeded" is precisely the
KB-15 defect this module's docstring describes.
Reconnecting after a disconnect: pass the id of the last
:class:StreamEvent this method yielded as
last_event_id to resume without a gap or a duplicate. The gateway
resumes strictly after that id — never send the JSON body's
sequence field here; see :attr:StreamEvent.id. Most callers should
prefer :meth:events_with_reconnect, which does this automatically.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
run_id
|
str
|
The execution run ID (from :meth: |
required |
last_event_id
|
str | None
|
Resume cursor from a previous connection's last
yielded :attr: |
None
|
Yields:
| Type | Description |
|---|---|
AsyncIterator[StreamEvent]
|
StreamEvent objects. |
AsyncIterator[StreamEvent]
|
|
AsyncIterator[StreamEvent]
|
|
AsyncIterator[StreamEvent]
|
|
Raises:
| Type | Description |
|---|---|
NovaOSAuthenticationError
|
If authentication fails. |
NovaOSConnectionError
|
If connection to the gateway fails. |
NovaOSServerError
|
If the gateway returns a server error. |
events_with_reconnect(run_id: str, *, max_retries: int = 5, initial_backoff: float = 1.0, max_backoff: float = 30.0) -> AsyncIterator[StreamEvent]
async
Subscribe to SSE events, transparently reconnecting on disconnect.
Wraps :meth:events, tracking the last :attr:StreamEvent.id seen and
passing it back as last_event_id on reconnect — the gateway
resumes exactly where the dropped connection left off, so every event
is yielded exactly once across any number of reconnects, with no gap
and no duplicate.
Only retryable failures (:attr:NovaOSError.is_retryable, e.g. a
dropped connection or a 502/503/504) trigger a reconnect; an
authentication failure or another non-retryable error propagates
immediately. Reconnect attempts use :func:_resilience.backoff_delay
(exponential backoff from initial_backoff seconds up to
max_backoff, with its default ±50% jitter so many clients
reconnecting after the same gateway blip do not retry in lockstep),
and the attempt count resets whenever an event is received —
max_retries bounds consecutive failed reconnect attempts with no
event received between them, not the total number of reconnects over a
long-lived stream.
Stops when a :attr:StreamEvent.is_terminal event is yielded, same as
:meth:events.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
run_id
|
str
|
The execution run ID (from :meth: |
required |
max_retries
|
int
|
Maximum consecutive reconnect attempts with no intervening event before giving up and raising. |
5
|
initial_backoff
|
float
|
Seconds to wait before the first reconnect. |
1.0
|
max_backoff
|
float
|
Upper bound on the backoff delay. |
30.0
|
Yields:
| Type | Description |
|---|---|
AsyncIterator[StreamEvent]
|
StreamEvent objects, exactly once each, in order. |
Raises:
| Type | Description |
|---|---|
NovaOSAuthenticationError
|
If authentication fails. |
NovaOSConnectionError
|
If reconnect attempts are exhausted. |
NovaOSServerError
|
If the gateway returns a non-retryable server error. |
execute(instance_id: str, input_text: str, *, thread_id: str | None = None, conversation_id: str | None = None, attachments: Sequence[Attachment] | None = None) -> ExecuteResult
async
Start agent execution via the Execution Gateway.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
instance_id
|
str
|
UUID of the installed agent instance to execute. |
required |
input_text
|
str
|
Plain-text input for the agent (required, max 100,000 chars). |
required |
thread_id
|
str | None
|
Optional thread UUID for conversation continuity. |
None
|
conversation_id
|
str | None
|
Optional conversation UUID for grouping runs. |
None
|
attachments
|
Sequence[Attachment] | None
|
Optional files to send with the request, each given
by |
None
|
Returns:
| Type | Description |
|---|---|
ExecuteResult
|
ExecuteResult with the |
Raises:
| Type | Description |
|---|---|
NovaOSAuthenticationError
|
If authentication or authorization fails. |
NovaOSConnectionError
|
If connection to the gateway fails. |
NovaOSServerError
|
If the gateway returns a server error. |
NovaOSError
|
If instance_id is missing, or an element of
|
streaming_health() -> bool
async
Check if real-time streaming is available (Redis configured).
Returns:
| Type | Description |
|---|---|
bool
|
True if the streaming subsystem is healthy. |
StreamEvent(type: str, content: str | None = None, timestamp: str | None = None, status: str | None = None, id: str | None = None)
dataclass
A single SSE event from the Execution Gateway streaming endpoint.
Attributes:
| Name | Type | Description |
|---|---|---|
type |
str
|
Event type — |
content |
str | None
|
Event payload (text chunk, or error message). |
timestamp |
str | None
|
Event timestamp as a string (milliseconds since epoch). |
status |
str | None
|
For a |
id |
str | None
|
The SSE |
is_failure: bool
property
Whether this event reports that the run did not succeed.
The KB-15 invariant a consumer needs: a terminal non-success state must
never be mistaken for success. A stream can end in failure without ever
producing an error event — the platform records a late-detected
failure as status/FAILED — so a consumer that watches only for
error will read that ending as a normal completion.
is_terminal: bool
property
Whether no further events will arrive for this run.
Branch on this rather than on event.type == "done". A run reaches
its end in more than one shape: done closes a live stream, error
aborts one, and a status event carrying a terminal run status is how
the event log records an ending — which is what a client replaying a
finished run sees, and what a failure detected after the agent already
returned looks like. Recognizing only the first two is the KB-15 defect
(see the module docstring).
Two things this deliberately does not do. It does not treat
WAITING_USER as terminal, because a HITL pause is not an ending. And
it does not treat an unrecognized status value as terminal, because
the gateway multiplexes agent-authored progress updates onto the same
event type; matching the platform's exact vocabulary is what keeps an
agent's own progress text from ending the stream early.
from_sse_data(event_type: str, data: str) -> StreamEvent
classmethod
Parse an SSE data: line into a StreamEvent.
The Execution Gateway sends all events as event: message with a
JSON body containing {type, payload, run_id, sequence}. The actual
event semantics are determined by the JSON type field:
message.delta→ content chunk (maps to"chunk")message.final→ the turn's complete agent message (replay only)done→ stream completeerror→ stream failedstatus→ run lifecycle status, or agent progresshitl→ human-in-the-loop prompt
status carries two different things under one type. The platform
publishes it with a status field naming a run lifecycle state; an
agent publishes it with a message field describing what it is doing.
Both are surfaced — :attr:StreamEvent.status is populated only when the
payload actually names one, which is what lets :attr:is_terminal
distinguish "this run ended" from "still working".
Any other type is passed through with its own name rather than being dropped, so a gateway-side addition reaches the caller instead of vanishing.
Legacy format (event: chunk|done|error) is also supported for
backward compatibility.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
event_type
|
str
|
The SSE |
required |
data
|
str
|
The SSE |
required |
Returns:
| Type | Description |
|---|---|
StreamEvent
|
Parsed StreamEvent. |
novaos_client_sdk.discovery
A2A Agent Card discovery — fetch and cache an agent's self-description.
Part of the ACP-to-A2A migration. This module used to ask
an ACP server GET /agents and hand back a list of
:class:novaos_client_sdk.models.Agent objects; it now fetches one
:class:~novaos_client_sdk.a2a_agent_card.AgentCard from
/.well-known/agent-card.json.
Why the shape changed, and not just the endpoint. GET /agents was a
listing: one ACP server fronted many separately named agents, so discovery
meant enumerating them and then filtering the result — which is what this
module's find_agents / find_one / get_or_raise surface existed to do.
A2A has no listing operation at all. An agent is a URL
(:mod:novaos_client_sdk.client explains why there is no agent= parameter
any more), and one well-known path describes exactly one agent. So the filter
API had nothing left to filter over and was removed outright rather than
reimplemented against a document that cannot answer it — the same call was made
on run_sync/run_stream. There are no aliases and no deprecation window,
per the migration's scope decision.
Enumeration did not disappear from the SDK — it moved to where it already
was. The migration's design settles this explicitly: the agent DNS service remains the
right place for internal discovery, and it is reached through
:class:novaos_client_sdk.dns.AgentDNSClient, which already offers
list_agents / search_agents / find_agents / get_agent over a
registry that genuinely holds many agents. External and internal discovery are
two different audiences: a caller that needs "which agents exist?" asks the DNS
service, and a caller that needs "what does this agent do, and where do I call
it?" asks for its Agent Card. This module is the second question only.
What answers the well-known path. Not the agent pod: NovaOS pods are never internet-reachable, so the execution gateway serves the card on a published agent's behalf. Nothing here depends on that, though — the request is the plain A2A one, so the same code discovers a third-party partner agent that hosts its own card, which is a case the migration's discovery design calls out.
The extended Agent Card is deliberately absent. A2A defines a second,
authenticated card carrying detail the public one omits, retrieved with the
GetExtendedAgentCard JSON-RPC method rather than from a URL. It is out of
scope here and stays that way: the migration's design requires that no
extended-card-shaped path is ever registered as public at the gateway, and a
fetcher in this module would be a second way to reach for it.
:attr:AgentCapabilities.extended_agent_card
<novaos_client_sdk.a2a_agent_card.AgentCapabilities.extended_agent_card> reports
whether the agent offers one.
AgentDiscoveryService(base_url: str, cache_ttl: int | None = DEFAULT_AGENT_CARD_CACHE_TTL, timeout: float | None = None, auth: Any | None = None, *, verify_signatures: bool = False, jwks_url: str | None = None, signature_algorithms: Sequence[str] | None = None, guard_connect_ips: bool = False, allow_private_ips: bool = False)
Fetches and caches one agent's Agent Card.
The service is an async context manager; the HTTP connection is opened on entry and reused, so repeated lookups against the same agent — skills, extensions, the interface URL — cost one request between cache expiries rather than one each.
Example
async with AgentDiscoveryService(base_url="https://gateway.example") as discovery:
card = await discovery.agent_card()
print(card.name, card.skill_ids)
async with await discovery.to_client(
allowed_host_suffixes=[".gateway.example"]
) as client:
task = await client.send_message("hello", skill_id=card.skill_ids[0])
Third-party partner agents
A partner's Agent Card is attacker-influenceable input: the interface
URL it advertises is where the client will dial, and any credentials
configured on the client travel with the request. Always pass
allowed_host_suffixes naming the partner's own domain (to_client
refuses to run without one), so the card
cannot redirect the client elsewhere. The private/loopback/link-local
IP block is applied on top of the allowlist, never instead of it, and
hostnames are checked in the same IDNA form the transport connects to.
Initialize the discovery service.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
base_url
|
str
|
Base URL the agent is addressed at. Its Agent Card is
read from |
required |
cache_ttl
|
int | None
|
Seconds to reuse a fetched card for. |
DEFAULT_AGENT_CARD_CACHE_TTL
|
timeout
|
float | None
|
Request timeout in seconds. |
None
|
auth
|
Any | None
|
Optional auth provider. The basic Agent Card is public by design — it is what a caller reads before it has credentials — so this is normally unnecessary; it exists for a deployment that puts the whole origin behind a gate. |
None
|
verify_signatures
|
bool
|
Verify the card's JWS |
False
|
jwks_url
|
str | None
|
JWKS endpoint to verify against. Required when
|
None
|
signature_algorithms
|
Sequence[str] | None
|
Acceptable JWS algorithms. See
:data: |
None
|
guard_connect_ips
|
bool
|
Resolve |
False
|
allow_private_ips
|
bool
|
With |
False
|
Raises:
| Type | Description |
|---|---|
NovaOSValidationError
|
If |
base_url: str
property
The agent base URL this service discovers.
card_url: str
property
The well-known URL this service reads the card from.
agent_card(*, force_refresh: bool = False) -> AgentCard
async
Return the agent's Agent Card, fetching it if the cache is cold.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
force_refresh
|
bool
|
Re-fetch even when a cached card is still valid. |
False
|
Returns:
| Type | Description |
|---|---|
AgentCard
|
The parsed :class: |
Raises:
| Type | Description |
|---|---|
NovaOSAgentNotFoundError
|
If no card is published at the well-known path (HTTP 404) — the agent is not discoverable, which for a gateway-hosted card means it was never published. |
NovaOSAuthenticationError
|
If the origin refused the request. |
NovaOSRateLimitError
|
If the origin rate-limited the request. |
NovaOSServerError
|
If the origin answered 5xx. |
NovaOSConnectionError
|
If the origin could not be reached. |
NovaOSValidationError
|
On any other 4xx, or if the response is not a usable Agent Card. |
NoAgentCardSignatureError
|
If |
InvalidAgentCardSignatureError
|
If |
clear_cache() -> None
Drop any cached card, forcing the next lookup to re-fetch.
extensions() -> list[AgentExtension]
async
Every A2A protocol extension the agent declares.
The read half of A2A's extension mechanism; the extension mechanism builds
A2A-Extensions header negotiation on top of it.
interface_url(protocol_binding: str = PROTOCOL_BINDING_JSONRPC) -> str | None
async
The URL to call the agent at, or None if it offers no such binding.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
protocol_binding
|
str
|
Which A2A binding to look for. Defaults to JSON-RPC, the only one this SDK's transport speaks. |
PROTOCOL_BINDING_JSONRPC
|
require_skill(skill_id: str) -> AgentSkill
async
The advertised skill with this id, or raise.
The counterpart of the removed get_or_raise: the useful "or raise"
question under A2A is about a skill, since which agent is being
addressed is already settled by the URL.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
skill_id
|
str
|
The skill id to require. |
required |
Raises:
| Type | Description |
|---|---|
NovaOSAgentNotFoundError
|
If the card advertises no such skill. The error carries the ids that are advertised, which is what a caller needs to correct the call. |
skill(skill_id: str) -> AgentSkill | None
async
The advertised skill with this id, or None.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
skill_id
|
str
|
The value a caller would send as
:data: |
required |
skills() -> list[AgentSkill]
async
Every skill the agent advertises.
supports_extension(uri: str) -> bool
async
Does the agent declare this extension?
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
uri
|
str
|
The extension's URI, matched exactly. |
required |
to_client(*, allowed_host_suffixes: list[str] | None = None, config: ClientConfig | None = None, **client_kwargs: Any) -> Client
async
Build a :class:~novaos_client_sdk.client.Client from the agent's card.
This is discovery's payoff: one of the card's interface URLs is
exactly what a client needs as its base_url, so a caller goes from
"an origin" to "a callable agent" without hard-coding a path.
Which interface, when a card offers several.
:func:~novaos_client_sdk.a2a_agent_card.select_interface picks one
from config.supported_protocol_bindings (JSON-RPC only if
unset — this SDK's original behavior, unchanged) and
config.use_client_preference (server preference, i.e. the card's
own listed order, unless set). A card offering only JSON-RPC and a
default config therefore behaves exactly as it always has.
The returned client's :attr:~novaos_client_sdk.client.Client.card is
this same cached card — by identity, not a re-parse — passed straight
through rather than discarded: this service already paid for it, and a
second HTTP request to re-fetch what is already in hand would be waste
for no gain.
The URL is validated before it is used. It arrives from a remote
document, so it is the classic SSRF vector — a card is the one place an
attacker who can influence published metadata gets to choose an address
the SDK will connect to. :func:novaos_client_sdk.dns.validate_agent_url
is the same guard :meth:novaos_client_sdk.dns.AgentDNSRecord.to_client
applies to a DNS record's URL, for the same reason — applied here to
whichever interface was selected, regardless of binding.
A host allowlist is required. The card's URL decides where the
client dials, and the client's headers/auth are sent to it, so
a card must not be able to redirect them to a host the caller never
named. allowed_host_suffixes is therefore mandatory, with no
opt-out; the private/loopback/link-local IP block applies on top of it.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
allowed_host_suffixes
|
list[str] | None
|
Hostname suffix allowlist naming the
domains the card's URL may point at ( |
None
|
config
|
ClientConfig | None
|
Per-client defaults, including which bindings this caller
can speak and whose preference order wins — see above. Also
forwarded to the constructed :class: |
None
|
**client_kwargs
|
Any
|
Forwarded to :class: |
{}
|
Returns:
| Type | Description |
|---|---|
Client
|
A client addressed at the selected interface. It is not entered — |
Client
|
|
Raises:
| Type | Description |
|---|---|
NovaOSValidationError
|
If the card advertises no interface this client can speak. |
NovaOSError
|
If no allowlist was given, or if the advertised URL fails the allowlist or the SSRF guard. |
fetch_agent_card(base_url: str, *, timeout: float | None = None, auth: Any | None = None, verify_signatures: bool = False, jwks_url: str | None = None, signature_algorithms: Sequence[str] | None = None) -> AgentCard
async
Fetch one agent's Agent Card, without keeping a service around.
The one-shot form of :meth:AgentDiscoveryService.agent_card, for the common
case of reading a card once at startup. Prefer the service when several
lookups follow, since this opens and closes a connection per call and caches
nothing.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
base_url
|
str
|
Base URL the agent is addressed at. |
required |
timeout
|
float | None
|
Request timeout in seconds. |
None
|
auth
|
Any | None
|
Optional auth provider. |
None
|
verify_signatures
|
bool
|
See :meth: |
False
|
jwks_url
|
str | None
|
See :meth: |
None
|
signature_algorithms
|
Sequence[str] | None
|
See :meth: |
None
|
Returns:
| Type | Description |
|---|---|
AgentCard
|
The parsed :class: |
novaos_client_sdk.dns
DNS client for agent discovery across the network.
AgentDNSClient(base_url: str, timeout: float | None = None, headers: dict[str, str] | None = None, allowed_host_suffixes: list[str] | None = None, trust_dns_urls: bool = False)
Client for discovering agents via DNS service.
Initialize DNS client.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
base_url
|
str
|
Base URL of the DNS service (e.g., "http://agent-dns.example.internal:8000") |
required |
timeout
|
float | None
|
Request timeout in seconds |
None
|
headers
|
dict[str, str] | None
|
Additional HTTP headers to include in requests |
None
|
allowed_host_suffixes
|
list[str] | None
|
Optional hostname suffix allowlist for agent URLs returned by DNS lookups when creating clients from records. |
None
|
trust_dns_urls
|
bool
|
Forwarded as |
False
|
find_agents(name: str | None = None, capability: str | None = None, capabilities: list[str] | None = None, require_all_capabilities: bool = True, fuzzy_match: bool = False, domain: str | None = None, tag: str | None = None, status: str | None = None) -> AsyncIterator[AgentDNSRecord]
async
Find agents by criteria with enhanced capability filtering.
When text-based filters (name, domain, tag) are provided, the method routes
through POST /agents/search for server-side pre-filtering, then applies
all remaining filters client-side. When only capability filters are provided,
it fetches all agents via GET /agents and filters client-side.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
name
|
str | None
|
Filter by agent name (partial match) |
None
|
capability
|
str | None
|
Filter by single capability name (deprecated, use capabilities) |
None
|
capabilities
|
list[str] | None
|
Filter by multiple capability names |
None
|
require_all_capabilities
|
bool
|
If True, agent must have all capabilities (AND logic). If False, agent needs at least one (OR logic) |
True
|
fuzzy_match
|
bool
|
If True, use fuzzy matching for capabilities |
False
|
domain
|
str | None
|
Filter by domain |
None
|
tag
|
str | None
|
Filter by tag |
None
|
status
|
str | None
|
Filter by status (e.g., "active", "inactive") |
None
|
Yields:
| Type | Description |
|---|---|
AsyncIterator[AgentDNSRecord]
|
AgentDNSRecord objects matching the criteria |
Raises:
| Type | Description |
|---|---|
NovaOSDNSConnectionError
|
If connection to DNS service fails |
NovaOSDNSError
|
For other DNS service errors |
get_agent(name: str) -> AgentDNSRecord | None
async
Get agent by name from DNS service.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
name
|
str
|
Agent name |
required |
Returns:
| Type | Description |
|---|---|
AgentDNSRecord | None
|
AgentDNSRecord if found, None otherwise |
Raises:
| Type | Description |
|---|---|
NovaOSDNSConnectionError
|
If connection to DNS service fails |
NovaOSDNSError
|
For other DNS service errors |
get_agent_client(name: str, **client_kwargs: Any) -> Client
async
Discover an agent by name and return a connected :class:Client.
Uses the allowed_host_suffixes configured on this DNS client
instance for URL validation before constructing the ACP client.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
name
|
str
|
Agent name to look up. |
required |
**client_kwargs
|
Any
|
Forwarded to :meth: |
{}
|
Returns:
| Name | Type | Description |
|---|---|---|
A |
Client
|
class: |
Raises:
| Type | Description |
|---|---|
NovaOSDNSAgentNotFoundError
|
If the agent is not found. |
NovaOSDNSConnectionError
|
On DNS service connectivity issues. |
list_agents() -> AsyncIterator[AgentDNSRecord]
async
List all agents from DNS service.
Yields:
| Type | Description |
|---|---|
AsyncIterator[AgentDNSRecord]
|
AgentDNSRecord objects |
Raises:
| Type | Description |
|---|---|
NovaOSDNSConnectionError
|
If connection to DNS service fails |
NovaOSDNSError
|
For other DNS service errors |
search_agents(query: str) -> AsyncIterator[AgentDNSRecord]
async
Search agents by query string.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
query
|
str
|
Search query string |
required |
Yields:
| Type | Description |
|---|---|
AsyncIterator[AgentDNSRecord]
|
AgentDNSRecord objects matching the query |
Raises:
| Type | Description |
|---|---|
NovaOSDNSConnectionError
|
If connection to DNS service fails |
NovaOSDNSError
|
For other DNS service errors |
AgentDNSRecord(id: str, name: str, url: str, description: str | None = None, metadata: dict[str, Any] | None = None, capabilities: list[str] = list(), domains: list[str] = list(), tags: list[str] = list(), created_at: datetime | None = None, updated_at: datetime | None = None, status: str = 'active')
dataclass
Agent DNS record with connection details.
is_available: bool
property
Check if agent is available.
parsed_capabilities: list[Capability]
property
Get parsed capabilities from metadata (property alias for get_capabilities).
from_dict(data: dict[str, Any]) -> AgentDNSRecord
classmethod
Create from dictionary.
get_capabilities() -> list[Capability]
Get parsed capabilities from metadata.
Returns:
| Type | Description |
|---|---|
list[Capability]
|
List of Capability objects parsed from metadata.capabilities |
get_domains() -> list[str]
Get domains from record field and metadata, merged.
Returns:
| Type | Description |
|---|---|
list[str]
|
List of domain strings, merging record.domains field and metadata.domains |
get_tags() -> list[str]
Get tags from record field and metadata, merged.
Returns:
| Type | Description |
|---|---|
list[str]
|
List of tag strings, merging record.tags field and metadata.tags |
has_capability(capability_name: str, fuzzy: bool = False) -> bool
Check if agent has specific capability.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
capability_name
|
str
|
Capability name to check |
required |
fuzzy
|
bool
|
If True, use fuzzy matching (threshold 0.8) |
False
|
Returns:
| Type | Description |
|---|---|
bool
|
True if agent has the capability |
to_client(*, allowed_host_suffixes: list[str] | None = None, trust_url: bool = False, **client_kwargs: Any) -> Client
Create ACP client from DNS record.
Trust is opt-in, not the default. This URL comes from the
Agent-DNS registry the caller explicitly configured and queried by
name — a platform-auth-gated internal source, not
attacker-influenceable metadata like a fetched Agent Card (see
AgentDiscoveryService.to_client, whose guard stays on
unconditionally for exactly that reason). That distinction is why
trust_url exists at all — but the sanctioned way to reach a
NovaOS agent pod is gateway-mediated (the auth-proxy sidecar on
:8000 validates the run's RS256 execution token); a direct
peer-to-peer dial to an agent's raw *.example.internal address is
off that golden path (EXPL-EXEC-008) regardless of whether the
private-IP-range guard lets the connection attempt through. Shipping
this opt-in as the default would make the SDK quietly steer callers
onto the off-golden-path route; leaving it off keeps the mechanism
available for a caller who knowingly needs a direct dial (e.g. a
deliberately unmediated internal tool) without making that the path
of least resistance. Whether/how a gateway-mediated connect helper is
added is a separate, later decision — not made by this flag.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
allowed_host_suffixes
|
list[str] | None
|
Optional hostname suffix allowlist passed to
:func: |
None
|
trust_url
|
bool
|
When True, skip the private/loopback/link-local/ reserved IP-range block for this URL. Defaults to False — see "Trust is opt-in, not the default" above. |
False
|
**client_kwargs
|
Any
|
Additional arguments to pass to Client constructor (e.g., timeout, headers, auth) |
{}
|
Returns:
| Type | Description |
|---|---|
Client
|
Client instance configured with this agent's URL |
to_dict() -> dict[str, Any]
Convert to dictionary.
discover_and_connect(agent: str | AgentDNSRecord, dns_base_url: str, dns_timeout: float | None = None, dns_headers: dict[str, str] | None = None, allowed_host_suffixes: list[str] | None = None, trust_dns_urls: bool = False, **client_kwargs: Any) -> AsyncIterator[Client]
async
Discover agent via DNS and create a connected client.
This is a convenience function that combines DNS discovery with client creation. It automatically handles cleanup when exiting the context.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
agent
|
str | AgentDNSRecord
|
Agent name (str) or AgentDNSRecord object. If an AgentDNSRecord is provided, it will be used directly without DNS lookup. |
required |
dns_base_url
|
str
|
Base URL of the DNS service |
required |
dns_timeout
|
float | None
|
Timeout for DNS service requests |
None
|
dns_headers
|
dict[str, str] | None
|
Headers for DNS service requests |
None
|
allowed_host_suffixes
|
list[str] | None
|
Optional hostname suffix allowlist for the discovered agent URL before client construction. |
None
|
trust_dns_urls
|
bool
|
Forwarded as |
False
|
**client_kwargs
|
Any
|
Additional arguments to pass to Client constructor (e.g., timeout, headers, auth) |
{}
|
Yields:
| Type | Description |
|---|---|
AsyncIterator[Client]
|
Client instance connected to the discovered agent |
Raises:
| Type | Description |
|---|---|
NovaOSDNSAgentNotFoundError
|
If agent is not found in DNS service |
NovaOSDNSConnectionError
|
If connection to DNS service fails |
NovaOSDNSError
|
For other DNS service errors |
Example
async with discover_and_connect(
agent="joke-agent",
dns_base_url="http://agent-dns.example.internal:8000"
) as client:
task = await client.send_message("Hello", skill_id="joke")
print(task.artifacts[0].text if task.artifacts else task.status.state)
Discovery is what supplies the agent's URL, and under A2A that URL is the agent — the returned client addresses it and nothing else, so no call inside the block names an agent again.
validate_agent_url(url: str, allowed_suffixes: list[str] | None = None, *, allow_private_ips: bool = False) -> None
Validate an agent URL hostname against suffix allowlist and blocked IP ranges.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
url
|
str
|
Agent base URL. Ordinarily carries a scheme
( |
required |
allowed_suffixes
|
list[str] | None
|
When provided and non-empty, hostname must end with at least one suffix. |
None
|
allow_private_ips
|
bool
|
When True, skip the private/loopback/link-local/
reserved IP-range block below (scheme parsing and the suffix
check above still run). Intended only for a URL sourced from a
trusted internal registry the caller explicitly configured — an
Agent-DNS record, not attacker-influenceable metadata such as a
fetched Agent Card. See |
False
|
Raises:
| Type | Description |
|---|---|
NovaOSError
|
If the URL cannot be parsed at all (e.g. an unterminated
IPv6 literal or an out-of-range port), if hostname fails the
suffix check, or if it resolves to a blocked IP. |
The A2A protocol layer
Types and wire constants for the Agent2Agent protocol itself — see the A2A primer for the concepts behind them.
novaos_client_sdk.a2a_types
A2A-native message types for the NovaOS client SDK.
These types implement the Message / Part family of the A2A protocol
specification v1.0.1 (a2aproject/A2A, tag v1.0.1). The migration
target is pinned to that specific release — not latest / @main, which is a moving
target that could silently invalidate these shapes. A2A_SPEC_VERSION below
records the pin in code so a future spec bump is a deliberate edit, not drift.
The 1.0.0 → 1.0.1 bump, evidence. git diff v1.0.0 v1.0.1 against
a2aproject/A2A (released 2026-05-26 per its CHANGELOG.md) touches
specification/a2a.proto in exactly 37 lines, all of them comments on the
tenant field of AgentInterface and of the nine request messages that
carry one — no field is added, removed, renumbered or retyped, so there is no
wire-format break and this bump needed no migration. Three things follow from
the fuller diff, none of which change what this SDK does on the wire:
AgentInterface.tenant's comment was rewritten from a description into a MUST: when a card interface declares a tenant, clients MUST send it back on every request to that interface. NovaOS already does this — :class:Clientresolvestenantfrom the card once at construction and sends it on every call (seeclient.py's_tenant_from_card/_apply_tenant) — so this SDK was already conformant with 1.0.1's tightened text before this bump landed.- The HTTP binding's docs now prefer
application/a2a+jsonoverapplication/json(CHANGELOG: "prefer application/a2a+json in HTTP binding", #1753). That is the HTTP+JSON binding, which this SDK does not implement; the JSON-RPC binding this SDK speaks keepsContent-Type: application/jsonin both spec releases, so nothing here changes. - A separate fix ("recent transcoding-related error changes", #1627) loosens
the JSON-RPC
error.datacontract — 1.0.0's text had, by the fixing PR's own account, mistakenly required a strictgoogle.rpc.ErrorInfoshape in JSON-RPC error data; 1.0.1 reverts that to "any object with an@typekey". This SDK never depended on the stricter reading: :func:~novaos_client_sdk.exceptions.a2a_error_from_json_rpchas always storederror.dataverbatim asdetails["data"]without assuming its shape, so it was already compatible with the relaxed 1.0.1 contract. The same commit also changes several HTTP/gRPC status-code mappings in the error table (e.g.TaskNotCancelableError409→400); those bindings are likewise not implemented here.
The net effect: this bump is a version-string update plus documentation, not a
behavior change. The next person bumping this pin should re-run the same
git diff v<old> v<new> -- specification/a2a.proto check and re-read the
release's CHANGELOG.md in full — not just the proto diff — since 1.0.1's
two most consequential fixes (the error-data contract change and the tenant
MUST) were both in prose outside the proto file, and would have been missed
by a proto-only diff.
Why NovaOS-owned dataclasses rather than a vendor SDK's concrete types
(DIP requirement, ticket-plan item 18): the SDK's own message code must not
depend directly on agentstack_sdk's (or any other vendor's) concrete types.
message_builder.py was the one place in the SDK that violated this — it
returned acp_sdk.models.Message by design. Building on these internal types
instead means a vendor client library, if one is ever adopted, is an
implementation detail behind
client.py, not a type that leaks through the SDK's public message API.
Wire format (corrected by F18). A2A v1.0 defines its data structures in
specification/a2a.proto, and every JSON binding — JSON-RPC included — carries
the proto3 JSON mapping of those messages: lowerCamelCase field names,
enums as their full value names, and a oneof rendered as whichever member is
set. These types originally emitted A2A v0.3's hand-rolled JSON instead
({"kind": "text", ...} parts, "user"/"agent" roles, a kind field
on Message). That shape is rejected outright by a v1.0 peer — the reference
implementation answers -32602 ... has no field named "kind" — so it was
corrected here rather than left to fail at integration time. Concretely, in
v1.0:
Parthas nokinddiscriminator. It is aoneofovertext,raw,urlanddata, withfilename,mediaTypeandmetadataas siblings available to every variant. A part is identified by which content field is present.Messagehas nokindfield either.Roleserialises asROLE_USER/ROLE_AGENT.- Text parts carry their media type in a real
mediaTypefield, so NovaOS no longer needs a metadata key to express one.
Version negotiation. A2A v1.0 §7 requires every client to send an
A2A-Version header; an absent header is read as 0.3, and a v1.0-only
agent answers VersionNotSupportedError. A2A_VERSION_HEADER and
A2A_PROTOCOL_VERSION below exist so callers send it consistently, and
:func:unsupported_version_reason is the server half — it is what
:meth:novaos_client_sdk.agent_builder.AgentBuilder.handle_a2a_request applies
so that a NovaOS pod and a vendor-SDK pod answer an identical request
identically.
Spec mapping notes (ADR F2):
- ACP
MessagePart(content=..., content_type=...)has no single A2A equivalent. A2A splits parts by content:textfor human-readable text,datafor structured JSON,raw/urlfor binary or referenced files. - ACP's
session_idmaps to A2A'scontextId.
A2AMessage(parts: list[Part] = list(), role: Role = Role.AGENT, message_id: str = (lambda: str(uuid4()))(), context_id: str | None = None, task_id: str | None = None, reference_task_ids: list[str] | None = None, extensions: list[str] | None = None, metadata: dict[str, Any] | None = None)
dataclass
A2A Message (spec v1.0.1).
Every factory method on :class:~novaos_client_sdk.message_builder.MessageBuilder
returns one of these. message_id is generated when not supplied, since
the spec requires it on every message.
text: str
property
Concatenate every TextPart's text — the human-readable content.
from_dict(data: dict[str, Any]) -> A2AMessage
classmethod
Create from the A2A JSON wire shape.
Accepts the JSON-RPC binding's camelCase keys, the gRPC binding's
snake_case equivalents, and v0.3's lowercase role spellings — reads
stay tolerant so a peer that has not caught up yet is still legible.
to_dict() -> dict[str, Any]
Convert to the A2A v1.0 JSON wire shape (proto3 JSON, camelCase).
DataPart(data: dict[str, Any], media_type: str | None = None, metadata: dict[str, Any] | None = None)
dataclass
A2A DataPart — structured JSON payload.
This is where NovaOS's structured metadata (HITL, error, progress) lives
under A2A. Under ACP it was a second MessagePart carrying a JSON
string with the vendor content-type application/vnd.novaos.metadata+json;
A2A models structured data as a first-class part variant, so the payload is
a real object rather than an embedded string.
from_dict(data: dict[str, Any]) -> DataPart
classmethod
Create from the A2A JSON wire shape.
to_dict() -> dict[str, Any]
Convert to the A2A v1.0 JSON wire shape.
FilePart(file: FileContent, metadata: dict[str, Any] | None = None)
dataclass
A2A file part — binary or URI-referenced content.
v1.0 has no nested file object: the content is the part's own raw or
url field, with filename and mediaType alongside. The
:class:FileWithBytes / :class:FileWithUri wrappers are kept because they
make which-variant-is-this explicit in Python; they are flattened on the way
out and reconstructed on the way in.
from_dict(data: dict[str, Any]) -> FilePart
classmethod
Create from the A2A JSON wire shape.
to_dict() -> dict[str, Any]
Convert to the A2A v1.0 JSON wire shape.
FileWithBytes(bytes: str, mime_type: str | None = None, name: str | None = None)
dataclass
Inline file content, base64-encoded.
Serialises into the enclosing Part (v1.0 has no nested file object):
bytes becomes the part's raw, name its filename, and
mime_type its mediaType.
FileWithUri(uri: str, mime_type: str | None = None, name: str | None = None)
dataclass
File content referenced by URI.
Serialises into the enclosing Part: uri becomes the part's url,
name its filename, and mime_type its mediaType.
PartKind
Bases: StrEnum
Which content variant of A2A's Part oneof a part carries.
v1.0 does not put this on the wire — a part is identified by which content
field is set. It is kept as an internal label because dispatching on a name
reads better than dispatching on "text" in data, and because callers
already branch on it.
Role
Bases: StrEnum
A2A message role (spec v1.0.1, Message.role).
The values are the proto enum's value names, which is what proto3 JSON
emits. v0.3's bare "user"/"agent" are accepted on the read side
only — see :meth:A2AMessage.from_dict.
TextPart(text: str, media_type: str | None = None, metadata: dict[str, Any] | None = None)
dataclass
A2A TextPart — human-readable text.
media_type maps to the part's own mediaType field. (Under v0.3 no
such field existed and NovaOS recorded the media type in metadata; v1.0
has a real one, so the workaround is gone — see message_builder.)
from_dict(data: dict[str, Any]) -> TextPart
classmethod
Create from the A2A JSON wire shape.
Coerced, not trusted — for the reasons in :meth:A2AMessage.from_dict.
A numeric text is the sharpest case: it survives construction and then
raises from :attr:A2AMessage.text, which joins the text parts, so the
failure surfaces during dispatch rather than at the boundary.
to_dict() -> dict[str, Any]
Convert to the A2A v1.0 JSON wire shape.
as_bool(value: Any) -> bool
Return value when it is a real bool, else False.
Deliberately not Python truthiness: the fields using this are proto3
booleans whose absence and whose false are indistinguishable on the
wire, so anything that is not an actual true means the same thing.
Coercing "false" — a string a hand-rolled server might emit — to True
by truthiness would invert the caller's meaning.
as_dict(value: Any) -> dict[str, Any]
Return value when it is a dict, else an empty dict.
Every nested envelope in an A2A payload is caller-controlled JSON, so any of
them can arrive as a list, a string or a number. or {} is not a
sufficient guard: it catches None and {}, but a truthy non-object
sails through and the next .get() raises AttributeError.
as_int(value: Any) -> int | None
Return value as an int, or None when it is not a whole number.
Wider than a bare isinstance check because proto3 JSON is: a 32-bit
integer is rendered as a JSON number, a 64-bit one as a string, and a
number that has been through a google.protobuf.Struct comes back as a
double (25.0). All three spell the same value, so all three are read.
bool is rejected ahead of int for the reason
:func:novaos_client_sdk.a2a_artifact._coerce_revision gives: True is an
int in Python, and "page size True" is not a page size.
as_str(value: Any) -> str
Return value when it is a string, else the empty string.
Used for the identifier fields. These are less a crash risk than a wire risk: proto3 JSON rejects a non-string where the schema says string, so an id accepted as a number and echoed back unchanged yields a payload no conformant peer can parse. Empty is already how every caller treats "absent".
as_str_list(value: Any) -> list[str] | None
Return the string members of value, or None when there are none.
None rather than [] because the fields using this are omitted from the
wire shape when empty, and an explicit empty list is not the same as absent.
is_a2a_shaped(message: Any) -> bool
Does this object present as an A2A Message?
v0.3 put a kind: "message" discriminator on the wire and NovaOS used it
to tell A2A messages apart from ACP ones. v1.0's Message has no such
field (F18), so the test is structural: an :class:A2AMessage, or anything
carrying the envelope the spec makes REQUIRED — messageId, role and
parts.
Duck-typing rather than isinstance alone is deliberate: call sites
receive messages built elsewhere (a decoded wire payload, a test double)
that are A2A-shaped without being this exact class.
part_from_dict(data: Any) -> Part
Deserialize a single A2A Part by which content field it carries.
v1.0's Part is a oneof, so the variant is identified by presence
rather than by a discriminator field.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
Any
|
An A2A part JSON object. Typed |
required |
Returns:
| Type | Description |
|---|---|
Part
|
The matching part dataclass. A part carrying none of the known content |
Part
|
fields falls back to :class: |
Part
|
future spec revision adding a variant) degrades to something renderable |
Part
|
rather than raising. |
requested_protocol_version(headers: Any) -> str
Return the A2A protocol version a request asked for.
Per v1.0 §7 an absent or empty A2A-Version header is not "unspecified"
— it means :data:A2A_ABSENT_VERSION. That is the whole reason a v1.0-only
agent must answer -32009 to a header-less call instead of assuming the
current version; see :func:unsupported_version_reason.
Header lookup is case-insensitive, per RFC 9110 §5.1. The reference
implementation only probes the canonical and all-lowercase spellings, so it
reads A2A-VERSION as absent where this returns its value. The
difference is unreachable behind a real HTTP server (ASGI lower-cases header
names before a handler sees them) and this side of it is the RFC-correct
one, so it is left as a deliberate superset rather than bug-compatibility.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
headers
|
Any
|
Any mapping with |
required |
Returns:
| Type | Description |
|---|---|
str
|
The requested version string, or :data: |
unsupported_version_reason(headers: Any) -> str | None
Return why this handler cannot serve headers' version, else None.
Returns a message rather than raising, so the caller decides how to frame it
— a JSON-RPC error object here, something else on another binding. The
wording is copied verbatim from the reference implementation
(a2a.utils.version_validator) so that a caller matching on the string
cannot tell a NovaOS pod from a vendor-SDK one; tests/ pins that.
Acceptance is major-version only, again matching the reference
implementation: 1.0, 1.0.0 and 1.4 are all served, 0.3 and
2.0 are not. The vendor's own docstring claims it compares major and
minor while its code compares major alone; the code is what pods actually
run, so it is what this matches. Narrowing to an exact 1.0 would make a
NovaOS pod reject a request a scaffolded pod accepts, which is precisely the
two-kinds-of-pod divergence this module exists to remove.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
headers
|
Any
|
Any mapping with |
required |
Returns:
| Type | Description |
|---|---|
str | None
|
A human-readable reason, or |
novaos_client_sdk.a2a_task
A2A Task types for the NovaOS client SDK.
Companion to :mod:novaos_client_sdk.a2a_types, which owns the Message /
Part family. These types implement the task-centric half of the A2A
protocol specification v1.0.1 (a2aproject/A2A, tag v1.0.1,
specification/a2a.proto) — the objects an agent returns rather than the
ones it receives.
Why a separate module rather than more of a2a_types: the message family and
the task family are used by different halves of the SDK (the builder emits
tasks; the client consumes messages), and keeping them apart means neither
side's work has to move through the other's file.
Wire format. A2A v1.0 defines its data structures in protobuf, and every
JSON binding carries the proto3 JSON mapping of them: lowerCamelCase
field names, and enums serialized as their full proto value names. That is why
:class:TaskState's values are the TASK_STATE_* strings and not v0.3's
lowercase "completed" / "input-required" spellings — a v1.0 peer reads
the enum value name off the wire.
Both directions, since the client transport landed. This module was emit-only while AgentBuilder
was its only user: an agent produces tasks, and parsing one off the wire was left
to the work that had a transport to feed it. :class:~novaos_client_sdk.client.Client
is that transport, so from_dict now exists on both classes here and
:class:TaskList models what ListTasks answers.
Reads are coerced, not trusted, exactly as
:meth:novaos_client_sdk.a2a_types.A2AMessage.from_dict is and for the same
reason: a Task arrives from a remote agent, so every field in it
is untrusted input. An unparseable state degrades to
:attr:TaskState.UNSPECIFIED rather than raising, because a client that cannot
read one field of a task should still be able to read its id — and because a
future spec revision adding a state must not turn every response into an
exception.
Design notes: the state mapping and the spec-version pin are documented in the migration's architecture decision record.
Task(id: str, status: TaskStatus, context_id: str | None = None, artifacts: list[Artifact] = list(), history: list[A2AMessage] = list(), metadata: dict[str, Any] | None = None)
dataclass
A2A Task (spec v1.0.1) — the unit of work a SendMessage creates.
artifacts holds the task's outputs, modelled as
:class:novaos_client_sdk.a2a_artifact.Artifact. It is what replaces ACP's
flat, unstructured Run.output.
status.message is not redundant with it. The two answer different
questions and A2A keeps both: the artifact is what the task produced, the
status message is what the agent has to say about reaching this state. A
terminal task always carries the latter — KB-15's failure mode is a finished
run with no explanation — while a task that failed before producing anything
legitimately carries no artifacts at all.
is_interrupted: bool
property
Has this task paused waiting for the caller to supply something?
is_terminal: bool
property
Has this task finished for good?
True only for :data:TERMINAL_TASK_STATES. A task paused on the
caller (:data:INTERRUPT_TASK_STATES) is not terminal even though
its stream closes — treating it as one is the misclassification this design
exists to prevent, and it is why :attr:is_interrupted is separate
rather than folded in here.
from_dict(data: Any) -> Task
classmethod
Create from the A2A JSON wire shape.
Accepts the JSON-RPC binding's camelCase keys and the gRPC binding's
snake_case equivalents, matching
:meth:novaos_client_sdk.a2a_types.A2AMessage.from_dict.
A task with no readable status still parses, carrying
:attr:TaskState.UNSPECIFIED. The alternative — raising — would leave a
caller unable to learn even the task's id from a response it did get,
and id is the field that lets it retry with GetTask.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
Any
|
An A2A |
required |
to_dict() -> dict[str, Any]
Convert to the A2A v1.0 JSON wire shape (proto3 JSON, camelCase).
TaskList(tasks: list[Task] = list(), next_page_token: str | None = None, page_size: int | None = None, total_size: int | None = None)
dataclass
A2A ListTasksResponse (spec v1.0.1) — one page of an agent's tasks.
ListTasks had no SDK representation at all before this class: ADR
finding F13 caught that it was missing from the migration's original client
method list, so nothing modelled either the request or its answer. It is the
one A2A operation with no ACP counterpart whatsoever — ACP could not
enumerate an agent's runs — which is why there is nothing here to map from.
next_page_token is the whole of the pagination contract: a non-empty
value means another page exists and is fetched by passing it back as
page_token. total_size is advisory — the spec does not require an
agent to know its total — so a caller paginates on the token and treats the
count as a display hint, never as a loop bound.
from_dict(data: Any) -> TaskList
classmethod
Create from the A2A JSON wire shape.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
Any
|
An A2A |
required |
to_dict() -> dict[str, Any]
Convert to the A2A v1.0 JSON wire shape (proto3 JSON, camelCase).
TaskState
Bases: StrEnum
A2A TaskState (spec v1.0.1).
Values are the proto enum's value names, which is what proto3 JSON emits. The ACP statuses these replace (ADR F2):
========================== ==========================
ACP A2A
========================== ==========================
created :attr:SUBMITTED
in-progress :attr:WORKING
completed :attr:COMPLETED
failed :attr:FAILED
awaiting_approval :attr:INPUT_REQUIRED
========================== ==========================
awaiting_approval was a custom NovaOS field on an ACP run; under A2A the
pause is a first-class protocol state.
TaskStatus(state: TaskState, message: A2AMessage | None = None, timestamp: str | None = None)
dataclass
A2A TaskStatus (spec v1.0.1) — a task's state plus optional detail.
message is how an agent says why a task reached this state. A
terminal state without one leaves the caller with a finished run and no
explanation, which is the failure mode recorded in KB-15 — so
:class:~novaos_client_sdk.agent_builder.AgentBuilder always attaches one
when it fails a task.
from_dict(data: Any) -> TaskStatus
classmethod
Create from the A2A JSON wire shape.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
Any
|
An A2A |
required |
to_dict() -> dict[str, Any]
Convert to the A2A v1.0 JSON wire shape (proto3 JSON, camelCase).
task_state_from_wire(value: Any) -> TaskState
Read a TaskState off the wire, degrading unknown values rather than raising.
A response comes from a remote agent, so state can be a spelling this
release has never seen — a future spec revision's new state, or simply
garbage. Neither justifies an exception out of a parser whose caller is
trying to find out what happened to its task, so anything unrecognized reads
as :attr:TaskState.UNSPECIFIED, which already means "no usable state".
v0.3's lowercase spellings ("completed", "input-required") are
not accepted. Tolerating them here would let a v0.3 peer look like it
was working while every other part of the exchange failed, which is the
looks-compliant-but-is-not shape ADR F18 found; a v0.3 pod is rejected at the
version header long before its task states matter.
utc_timestamp() -> str
Return an RFC 3339 / ISO 8601 UTC timestamp.
A2A's TaskStatus.timestamp is a protobuf Timestamp, which proto3
JSON renders as an RFC 3339 string in UTC with a Z suffix.
datetime.isoformat() writes +00:00 for UTC, so the offset is
normalized here rather than at each call site.
Always fixed-width, including the fractional seconds — strftime's
%f is always exactly six digits, unlike isoformat(), which omits
the fraction entirely when the microsecond happens to be zero. That
omission is not merely cosmetic: two RFC 3339 UTC-Z timestamps of
consistent width compare correctly with a plain string comparison, but
"...T00:00:00Z" (no fraction) sorts after
"...T00:00:00.500000Z" lexicographically — "Z" > "." in ASCII —
even though the first instant is earlier. ListTasks'
statusTimestampAfter filter in agent_builder.py relies on exactly
this format being comparable as a plain string, which a timestamp that
ever dropped its fraction would silently violate.
novaos_client_sdk.a2a_artifact
A2A Artifact types for the NovaOS client SDK.
Third module of the A2A type family, after :mod:novaos_client_sdk.a2a_types
(the Message / Part family) and
:mod:novaos_client_sdk.a2a_task (the Task family). An
Artifact is a task's output: the typed, named, versioned thing an agent
produced, as distinct from the message it sent explaining itself.
Why this exists. ACP had no equivalent concept at
all. A run's result was Run.output, a flat list[Message] — unstructured
by construction: nothing named an output, nothing said what type it was beyond a
content-type string on each part, and a second version of the same output was
just another message appended to the list, indistinguishable from a different
output. A2A makes outputs first-class, and this module is where NovaOS models
them. Run (models.py) and Task (a2a_task.py) both carry
artifacts now; neither carries a flat message list.
Typed, named, versioned — how each half is actually delivered:
- Typed: :attr:
Artifact.partsis the samePartunion messages use, so structured output is a real :class:~novaos_client_sdk.a2a_types.DataPartholding a JSON object, a file is a :class:~novaos_client_sdk.a2a_types.FilePart, and prose is a :class:~novaos_client_sdk.a2a_types.TextPartwith its ownmediaType. Under ACP a structured result was a JSON string inside a text part. - Named: :attr:
Artifact.artifact_idis the identity (unique within a task, generated when not supplied — the spec marks it REQUIRED), with :attr:Artifact.nameand :attr:Artifact.descriptionas the human-readable labels. - Versioned: :attr:
Artifact.revision, plus :meth:Artifact.revisedto advance it while keeping the identity. See below for why the revision travels inmetadatarather than as a field of its own.
Wire format. A2A v1.0 defines Artifact in specification/a2a.proto
and every JSON binding carries the proto3 JSON mapping of it, so the keys are
artifactId, name, description, parts, metadata and
extensions. That list is closed: a conformant peer parses with
unknown-field rejection on and answers -32602 ... has no field named "x" for
anything else — the exact failure F18 found when NovaOS emitted A2A v0.3's
hand-rolled JSON at a v1.0 peer.
That closed list is why revision is not a top-level key. A2A has no
version field on Artifact; the spec expresses versioning through
TaskArtifactUpdateEvent, where a later chunk carrying the same
artifactId updates or appends to the earlier one. So a NovaOS revision
counter cannot be its own field without producing a payload no conformant peer
will parse. It is carried instead under :data:ARTIFACT_REVISION_KEY inside
metadata, which is a google.protobuf.Struct and is the spec's own
extension point for exactly this. :meth:Artifact.from_dict lifts the key back
out into :attr:Artifact.revision, so the counter is a typed attribute in
Python and a metadata entry on the wire, and metadata never carries a
duplicate of it.
No TaskArtifactUpdateEvent here. It is the streaming frame for
incremental artifact delivery, and this SDK has no streaming surface yet —
sendStreamingMessage/subscribeToTask come with the transport work that
follows. Modelling the
frame before the transport that carries it would be a stub that later work has to migrate
off, which is the mistake :class:~novaos_client_sdk.a2a_task.Task avoided by
leaving artifacts to this module. :meth:Artifact.revised already carries
the append semantics that frame needs, so the transport work adds an envelope, not a model.
Design notes: the SDK gap this fills, the spec-version pin, and the wire format are documented in the migration's architecture decision record.
Artifact(parts: list[Part] = list(), artifact_id: str = (lambda: str(uuid4()))(), name: str | None = None, description: str | None = None, metadata: dict[str, Any] | None = None, extensions: list[str] | None = None, revision: int = DEFAULT_ARTIFACT_REVISION)
dataclass
A2A Artifact (spec v1.0.1) — one typed, named, versioned task output.
parts comes first to mirror
:class:~novaos_client_sdk.a2a_types.A2AMessage, whose shape this otherwise
parallels; the remaining field order follows the proto. The spec marks
parts REQUIRED ("must contain at least one part") but construction does
not raise on an empty one: :meth:from_dict reads untrusted wire data,
and a validating constructor would turn a peer's malformed payload into an
unhandled exception at the boundary — the same failure mode fixed in
the message types. Emitters in this SDK never build an empty one.
text: str
property
Concatenate every TextPart's text — the human-readable output.
The artifact-side counterpart of
:attr:novaos_client_sdk.a2a_types.A2AMessage.text, so a caller reads an
output the same way whether it arrived as a message or an artifact.
from_dict(data: Any) -> Artifact
classmethod
Create from the A2A JSON wire shape.
Accepts the JSON-RPC binding's camelCase keys and the gRPC binding's
snake_case equivalents, and coerces every field it reads for the
reason :meth:novaos_client_sdk.a2a_types.A2AMessage.from_dict records:
tolerance that yields an object :meth:to_dict cannot re-serialize is a
trap, not a kindness. A missing artifactId is replaced by a
generated one because the spec makes it REQUIRED, so re-emitting the
artifact without one would produce a payload no peer can parse.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
Any
|
An A2A artifact JSON object. Typed |
required |
revised(parts: list[Part], *, append: bool = False) -> Artifact
Return the next revision of this artifact, keeping its identity.
Identity is what makes a revision a revision: artifact_id, name
and description are carried over unchanged, so a consumer that keyed
on the id sees an update rather than a second, unrelated output — which
is precisely what ACP's flat Run.output list could not express.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
parts
|
list[Part]
|
The new content. |
required |
append
|
bool
|
When |
False
|
Returns:
| Type | Description |
|---|---|
Artifact
|
A new :class: |
to_dict() -> dict[str, Any]
Convert to the A2A v1.0 JSON wire shape (proto3 JSON, camelCase).
Optional fields are omitted when empty rather than sent as null,
matching the rest of the A2A types: proto3 JSON reads an absent field
and an explicit default identically, and omitting keeps the payload
readable. :attr:revision is folded into metadata under
:data:ARTIFACT_REVISION_KEY — and only when it is past the default, so
an artifact nobody has revised carries no metadata it did not ask for.
novaos_client_sdk.a2a_events
A2A streaming frames for the NovaOS client SDK.
Fourth module of the A2A type family, after :mod:novaos_client_sdk.a2a_types
(Message / Part), :mod:novaos_client_sdk.a2a_task
(Task) and :mod:novaos_client_sdk.a2a_artifact (Artifact).
This one models what arrives over time rather than what arrives
at the end: the frames SendStreamingMessage and SubscribeToTask deliver
over SSE.
It exists now because the SDK finally added the transport that carries these
frames. a2a_artifact deliberately left TaskArtifactUpdateEvent
unmodelled for exactly that reason — modelling a streaming frame before there
was a stream would have been a stub to migrate off — and noted that the work
which did add it would be "adding an envelope, not a model", which is what this
module is: :class:~novaos_client_sdk.a2a_artifact.Artifact and
:class:~novaos_client_sdk.a2a_task.TaskStatus do the work, and the classes here
say which task they belong to.
StreamResponse is a oneof (spec v1.0.1, a2a.proto) over task,
message, statusUpdate and artifactUpdate, so — as everywhere else in
proto3 JSON — the populated key is the discriminator. A2A v0.3 tagged each
frame with kind: "status-update" and carried a final boolean; v1.0 has
neither.
There is no final flag, and that is the point of this design. Because v1.0
removed it, a caller cannot ask a frame whether the stream is over — it has to
read the task state. A stream that closes without a terminal or interrupt
state is a transport failure, not a completed run, and reporting it as success is
the bug this terminality rule exists to prevent. :func:is_last_frame is where that rule
lives on this side so no consumer has to re-derive it.
Parity with the TypeScript caller. The platform's A2A wire contract parses
these same frames for the Temporal worker and the gateway's pod-caller. Both
accept a frame either bare
({"statusUpdate": …}) or wrapped in a JSON-RPC success envelope, for the same
reason: a2a-sdk emits the envelope form and a hand-rolled pod server should
not be silently unreadable.
Design notes: the A2A spec version pin, the wire format, and this terminality rule are documented in the migration's architecture decision record.
TaskArtifactUpdateEvent(artifact: Artifact, task_id: str | None = None, context_id: str | None = None, append: bool = False, last_chunk: bool = False, metadata: dict[str, Any] | None = None)
dataclass
A2A TaskArtifactUpdateEvent (spec v1.0.1) — a task produced output.
append is how A2A expresses artifact versioning, and it is why
:class:~novaos_client_sdk.a2a_artifact.Artifact has no version field of its
own: a later frame carrying the same artifactId either extends the
earlier content (append=True, a chunk of one streamed artifact) or
replaces it wholesale (append=False, a new revision).
:meth:novaos_client_sdk.a2a_artifact.Artifact.revised already implements
both, so :meth:apply_to is a two-line dispatch rather than new semantics.
last_chunk says this artifact is complete. It says nothing about the
task — an agent can finish one artifact and keep working — so it must not
be used as a stream-terminality signal. That is :func:is_last_frame's job,
reading the task state, per the stream-terminality rule.
apply_to(artifacts: list[Artifact]) -> list[Artifact]
Fold this update into a list of artifacts, keyed by artifact_id.
Returns a new list; the input is left alone. An update for an id not
yet seen appends the artifact — which is what the first frame of every
artifact is — and an update for a known id revises it, extending or
replacing its parts according to :attr:append.
This is the accumulation a streaming caller would otherwise write by hand at every call site, and getting it wrong is how a token-by-token artifact ends up either truncated to its last chunk or duplicated.
from_dict(data: Any) -> TaskArtifactUpdateEvent
classmethod
Create from the A2A JSON wire shape.
to_dict() -> dict[str, Any]
Convert to the A2A v1.0 JSON wire shape (proto3 JSON, camelCase).
TaskStatusUpdateEvent(status: TaskStatus, task_id: str | None = None, context_id: str | None = None, metadata: dict[str, Any] | None = None)
dataclass
A2A TaskStatusUpdateEvent (spec v1.0.1) — a task changed state.
task_id is carried on the frame rather than inferred from the request
because a single SubscribeToTask stream is scoped to one task but a
SendStreamingMessage one need not be: the spec lets an agent report on a
task it created mid-stream. Reading the id off the frame is therefore the
only correlation that is always right.
from_dict(data: Any) -> TaskStatusUpdateEvent
classmethod
Create from the A2A JSON wire shape.
to_dict() -> dict[str, Any]
Convert to the A2A v1.0 JSON wire shape (proto3 JSON, camelCase).
is_last_frame(frame: StreamFrame) -> bool
Does this frame carry a task state after which no more frames arrive?
True for both terminal states (the task is finished) and interrupt states
(the task has paused for the caller) — the SSE stream closes on either, a
fact confirmed by an A2A maintainer on
a2aproject/A2A#1503 <https://github.com/a2aproject/A2A/issues/1503>_.
This is not "did the task succeed". The two questions are deliberately
separate, because conflating them is a real bug: a stream that ends on
TASK_STATE_INPUT_REQUIRED is over and unfinished, and a run whose
stream simply closes with no such frame at all failed in transport and must
not be reported as complete. Callers ask this to know when to stop reading,
and ask the task's own state to know what happened.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
frame
|
StreamFrame
|
A decoded stream frame. |
required |
Returns:
| Type | Description |
|---|---|
bool
|
|
stream_frame_from_dict(data: Any) -> StreamFrame | None
Decode one StreamResponse payload, or None if it is not one.
Accepts the frame bare or wrapped in a JSON-RPC success envelope, matching
the TypeScript caller's parseStreamFrame — a2a-sdk emits the wrapped
form, and a hand-rolled pod server that emits the bare one should still be
readable.
Returns None rather than raising on anything unrecognizable. One
undecodable frame must not abort a run that is otherwise progressing, and
the caller's stream-close classification (:func:is_last_frame) is
what decides whether the run actually succeeded. A JSON-RPC error
envelope is not handled here — it is a failed call rather than a frame, and
:mod:novaos_client_sdk.client raises it as an
:class:~novaos_client_sdk.exceptions.A2AProtocolError before reaching this
function.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
Any
|
One already-JSON-decoded SSE |
required |
Returns:
| Type | Description |
|---|---|
StreamFrame | None
|
The frame's populated member, or |
novaos_client_sdk.a2a_agent_card
A2A AgentCard types for the NovaOS client SDK.
Fourth module of the A2A type family, after :mod:novaos_client_sdk.a2a_types
(the Message / Part family),
:mod:novaos_client_sdk.a2a_task (the Task family) and
:mod:novaos_client_sdk.a2a_artifact (task outputs). An Agent
Card is how an agent describes itself: what it is called, where it can be
reached, which protocol bindings it speaks, which skills it offers, and which
protocol extensions it has declared.
Why this exists. ACP had no discovery mechanism at
all. discovery.py asked an ACP server GET /agents and got back a list of
free-form {name, description, capabilities, metadata} objects — a NovaOS
convention, not a protocol, describing agents that all lived behind the one
server that answered. A2A replaces that with a document at a fixed well-known
path, and nothing in the SDK could read one until this module: a pod
being an A2A agent and the gateway serving the card were both already
covered elsewhere, but until this module there was no half that consumes it.
The card is the client's entry point, because under A2A an agent is a URL.
:class:novaos_client_sdk.client.Client takes no agent= parameter — see its
module docstring — so the question "where do I point a client?" has exactly one
protocol-level answer, and it is :attr:AgentInterface.url from this document.
:meth:AgentCard.interface_url is that lookup.
Who serves it. Not the agent pod. NovaOS agent pods are never
internet-reachable, so AgentBuilder deliberately serves no card, the
Galaxy scaffold generates card content without a route, and
the platform's execution gateway hosts /.well-known/agent-card.json on every published
agent's behalf. This module does not care which of them answered: it
reads whatever is at the well-known path of the base URL it is given, so it
works equally against the NovaOS gateway and against a third-party partner
agent.
Extensions are modelled, not just carried. A2A declares
protocol extensions on capabilities.extensions[], and this module builds
declaration/negotiation — the A2A-Extensions header half — on top of that
declaration. :class:AgentExtension and the
:meth:AgentCard.extension / :attr:AgentCard.required_extension_uris
accessors are the read side that negotiation runs against, so it adds a
header and a policy rather than a second parse of the same field.
Wire format. A2A v1.0 defines AgentCard in specification/a2a.proto
and every JSON binding carries the proto3 JSON mapping of it: lowerCamelCase
keys, supportedInterfaces rather than a bare url, and securitySchemes
values wrapped in their oneof variant name (httpAuthSecurityScheme).
The same mapping the Galaxy scaffold's generator emits — the two are checked
against each other by tests/test_a2a_agent_card.py, and against the real
a2a-sdk protobuf by tests/test_a2a_v1_conformance.py, so a field this
module misspells fails a test rather than silently reading as absent.
Two fields are carried as opaque JSON on purpose: securitySchemes and
securityRequirements. SecurityScheme is a oneof over five scheme
types (API key, HTTP auth, OAuth2, OpenID Connect, mTLS), and this SDK never
acts on the declaration: every call into NovaOS authenticates with the
execution token the gateway injects, so a typed model here would be five
dataclasses that nothing reads. They round-trip byte-for-byte instead, which is
what a consumer forwarding a card actually needs, and modelling them later is an
additive change rather than a correction.
signatures is carried the same untyped way, but for a different reason:
this module never itself acts on it, and verifying it needs the raw JWS
strings byte-for-byte, not a parsed dataclass. JWS verification lives in
:mod:novaos_client_sdk.a2a_card_signing instead, as an opt-in
check :class:~novaos_client_sdk.discovery.AgentDiscoveryService can run after
a card is fetched and before it is cached or trusted. Opt-in, and off by
default, because every card this SDK has consumed so far arrives over a
trusted path from NovaOS's own gateway, and an unsigned card from that path
must keep working. It stops being optional the day this SDK fetches its first
third-party card, whose only other guarantee is
:func:novaos_client_sdk.dns.validate_agent_url's SSRF allowlist — a check on
where the advertised URL points, not on whether the card that named it was
tampered with.
Not to be confused with :mod:novaos_client_sdk.agent_card,
the python -m novaos_client_sdk.agent_card entry point that emits a
running agent's card as JSON — this module only defines the AgentCard
type that emitter serializes.
AgentCapabilities(streaming: bool = False, push_notifications: bool = False, extended_agent_card: bool = False, extensions: list[AgentExtension] = list())
dataclass
A2A AgentCapabilities — optional protocol features an agent supports.
Every field is a negative-by-default declaration: an absent capabilities
block and one with all three booleans false mean the same thing, because
proto3 JSON cannot tell an omitted boolean from a false one. So a caller
reads "streaming is not offered" rather than "streaming is unknown", and
there is no third state to represent.
from_dict(data: Any) -> AgentCapabilities
classmethod
Create from the A2A JSON wire shape.
to_dict() -> dict[str, Any]
Convert to the A2A v1.0 JSON wire shape.
The three booleans are always emitted, even when false. Proto3 JSON
would let them be omitted, but a card that states its non-support is
materially easier to debug than one that is silent about it — and it is
what the Galaxy scaffold's generator writes, so emitting the same keys
keeps a card read here and re-emitted byte-identical to its source.
AgentCard(name: str = '', description: str = '', version: str = '', supported_interfaces: list[AgentInterface] = list(), capabilities: AgentCapabilities = AgentCapabilities(), default_input_modes: list[str] = list(), default_output_modes: list[str] = list(), skills: list[AgentSkill] = list(), provider: AgentProvider | None = None, documentation_url: str | None = None, icon_url: str | None = None, security_schemes: dict[str, Any] = dict(), security_requirements: list[dict[str, Any]] = list(), signatures: list[dict[str, Any]] = list())
dataclass
A2A AgentCard (spec v1.0.1) — an agent's self-description.
Construction never validates, and :meth:from_dict never raises. This is
the same rule the rest of the A2A type family follows and for the same
reason that applies to the message types: from_dict reads
untrusted wire data, so a validating parser turns a peer's malformed payload
into an unhandled exception at the boundary instead of an error the caller
can handle. Deciding that a fetched document is too broken to be a card
is policy, and it lives one layer up in
:class:novaos_client_sdk.discovery.AgentDiscoveryService, which has the URL
and the HTTP context needed to say anything useful about it.
extension_uris: list[str]
property
The URI of every declared extension, in declaration order.
extensions: list[AgentExtension]
property
Every protocol extension this agent declares.
Lifted from capabilities.extensions so a caller negotiating
extensions reads one attribute rather than reaching two
levels into the card for the field it actually wants.
required_extension_uris: list[str]
property
The URI of every extension the agent marks required.
The set a caller must activate to be answered at all: invoking without
one is what A2A's -32008
(:class:~novaos_client_sdk.exceptions.ExtensionSupportRequiredError)
reports. Extension negotiation starts from this list.
skill_ids: list[str]
property
The id of every advertised skill, in card order.
extension(uri: str) -> AgentExtension | None
Return the declared extension with this URI, or None.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
uri
|
str
|
The extension's URI, matched exactly. A2A extension URIs are opaque identifiers, so no normalisation is applied — two URIs that differ only by a trailing slash are two extensions. |
required |
from_dict(data: Any) -> AgentCard
classmethod
Create from the A2A JSON wire shape.
Accepts the JSON binding's camelCase keys and the gRPC binding's
snake_case equivalents, and coerces every field it reads, for the
reason :meth:novaos_client_sdk.a2a_types.A2AMessage.from_dict records:
tolerance that yields an object :meth:to_dict cannot re-serialize is a
trap, not a kindness.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
Any
|
An A2A Agent Card JSON object. Typed |
required |
interface(protocol_binding: str = PROTOCOL_BINDING_JSONRPC) -> AgentInterface | None
Return the first interface serving this protocol binding, or None.
For picking among several bindings a card offers, by preference —
this SDK's or the card's own — use :func:select_interface instead;
this method is for the narrower "does this card offer binding X at
all" question, and returns the first match if a card lists more than
one interface for the same binding (different tenants, say).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
protocol_binding
|
str
|
One of :data: |
PROTOCOL_BINDING_JSONRPC
|
interface_url(protocol_binding: str = PROTOCOL_BINDING_JSONRPC) -> str | None
Return the URL to point a client at, or None if none is offered.
None rather than a fallback to a different binding: a caller
asking for one specific binding by name wants that binding or nothing,
and silently substituting another would turn a clear "not offered"
into a confusing protocol error at the first request. Picking among
several bindings a card offers is :func:select_interface's job, not
this method's.
skill(skill_id: str) -> AgentSkill | None
Return the advertised skill with this id, or None.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
skill_id
|
str
|
The value a caller would send as
:data: |
required |
supports_extension(uri: str) -> bool
Is this extension declared on the card?
to_dict() -> dict[str, Any]
Convert to the A2A v1.0 JSON wire shape (proto3 JSON, camelCase).
Optional fields are omitted when empty rather than sent as null,
matching the rest of the A2A types. The REQUIRED ones — name,
description, version, supportedInterfaces, capabilities,
defaultInputModes, defaultOutputModes and skills — are always
emitted, so a card that lost a required field on the way in does not
quietly lose it again on the way out.
AgentExtension(uri: str = '', description: str | None = None, required: bool = False, params: dict[str, Any] | None = None)
dataclass
A2A AgentExtension — one protocol extension an agent declares.
The declaration half of A2A's extension mechanism. :attr:required is the
field that carries obligation: an agent may offer an extension a caller
can ignore, or demand one, and a caller that invokes an agent without
activating a required extension is answered -32008
(:class:~novaos_client_sdk.exceptions.ExtensionSupportRequiredError).
Extension negotiation turns this declaration into the A2A-Extensions
request header; this module only reports what was declared.
from_dict(data: Any) -> AgentExtension
classmethod
Create from the A2A JSON wire shape.
to_dict() -> dict[str, Any]
Convert to the A2A v1.0 JSON wire shape (proto3 JSON, camelCase).
AgentInterface(url: str = '', protocol_binding: str = PROTOCOL_BINDING_JSONRPC, protocol_version: str | None = None, tenant: str | None = None)
dataclass
A2A AgentInterface — one address at which an agent answers.
A list rather than a single url because one agent may serve several
protocol bindings at different addresses. :attr:protocol_binding is what
tells them apart, and a client must pick the one it can actually speak
rather than the first entry: reading supportedInterfaces[0].url and
POSTing JSON-RPC at a gRPC endpoint is the failure this field exists to
prevent.
from_dict(data: Any) -> AgentInterface
classmethod
Create from the A2A JSON wire shape.
to_dict() -> dict[str, Any]
Convert to the A2A v1.0 JSON wire shape.
AgentProvider(organization: str = '', url: str = '')
dataclass
A2A AgentProvider — who publishes the agent.
Both fields are REQUIRED by the spec when the block is present at all, which is why the Galaxy generator omits the whole block for an author with no URL rather than emitting half of one.
from_dict(data: Any) -> AgentProvider
classmethod
Create from the A2A JSON wire shape.
to_dict() -> dict[str, Any]
Convert to the A2A v1.0 JSON wire shape.
AgentSkill(id: str = '', name: str = '', description: str = '', tags: list[str] = list(), examples: list[str] = list(), input_modes: list[str] = list(), output_modes: list[str] = list(), security_requirements: list[dict[str, Any]] = list())
dataclass
A2A AgentSkill — one named thing an agent can do.
A skill is not an ACP agent, and the difference is why discovery changed
shape. An ACP server fronted many separately named agents, so
GET /agents returned a list and a caller picked a name from it. A2A
models one agent whose card advertises many skills, so the list moved
inside the card and the selector moved onto the request — see
:data:novaos_client_sdk.client.SKILL_ID_METADATA_KEY, which is where
:attr:id is sent when calling.
from_dict(data: Any) -> AgentSkill
classmethod
Create from the A2A JSON wire shape.
to_dict() -> dict[str, Any]
Convert to the A2A v1.0 JSON wire shape.
id, name, description and tags are always emitted: the
spec marks all four REQUIRED, and omitting an empty tags would
produce a skill a conformant peer rejects.
select_interface(interfaces: list[AgentInterface], supported_bindings: list[str], *, use_client_preference: bool = False) -> AgentInterface | None
Pick the interface to call, from a card's several and a client's several.
Before multi-binding support existed, :meth:AgentDiscoveryService.to_client
<novaos_client_sdk.discovery.AgentDiscoveryService.to_client> looked for
exactly one binding (JSON-RPC) and gave up if the card did not offer it.
Once a client can speak more than one binding, "which interface" has two
reasonable answers depending on whose ordering should win, and this
function is both — the flag says which:
- Server preference (
use_client_preference=False, the default). Walk interfaces in the card's own listed order; return the first whose binding is anywhere in supported_bindings. The agent's preference decides which binding is used; the client's list is only a filter of what it is able to speak at all. - Client preference (
use_client_preference=True). Walk supported_bindings in the client's order instead; return the first interface matching each, in turn. The client's own priority decides.
Mirrors the reference a2a-python ClientFactory.create's identical
precedence, minus its protocol-version-priority tiebreaking (picking
among several interfaces that already share one binding but differ in
protocol_version) — not needed here, since no NovaOS surface or
calling convention this ticket covers publishes more than one version per
binding; :meth:AgentInterface.protocol_version handling is otherwise
unchanged.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
interfaces
|
list[AgentInterface]
|
A card's |
required |
supported_bindings
|
list[str]
|
The bindings a client is able to speak, most
preferred first. An empty list matches nothing — pass
|
required |
use_client_preference
|
bool
|
See above. |
False
|
Returns:
| Type | Description |
|---|---|
AgentInterface | None
|
The chosen :class: |
AgentInterface | None
|
binding is in supported_bindings at all. |
novaos_client_sdk.a2a_jsonrpc
JSON-RPC 2.0 framing for A2A's primary transport binding.
A2A v1.0.1 ships three transport bindings; JSON-RPC 2.0 over HTTPS is the
primary one, and it is what NovaOS agent pods speak. This module owns the
envelope, in both directions, kept separate from
:mod:novaos_client_sdk.a2a_types (the data model) and
:mod:novaos_client_sdk.agent_builder (the dispatch).
Both directions, deliberately in one module. :func:parse_request /
:func:success_response / :func:error_response are the server half, used by
AgentBuilder; :func:request_envelope / :func:parse_response are the
client half, used by :class:novaos_client_sdk.client.Client. They
live together because they are two halves of one contract — the jsonrpc
literal, the batch-rejection rule and the id-echo rule have to agree, and a
second hand-rolled copy on the client side is precisely how they would stop
agreeing.
Method names are PascalCase in v1.0.1. The spec's method-mapping reference
(§5.3) names the operations SendMessage, GetTask, CancelTask and so
on. A2A v0.3 used slash-separated names (message/send, tasks/get);
those are not v1.0 names, and a v1.0 peer answers -32601 Method not found
for them. This matters because the ADR's own F2 concept-mapping table quotes
the v0.3 spelling — see the note in that ticket's PR.
Error codes here are the JSON-RPC transport errors only (-32700 …
-32603). They mean "this was not a well-formed call", and are deliberately
not modelled as exceptions: A2A's own nine protocol errors (-32001 …
-32009) are what
:class:novaos_client_sdk.exceptions.A2AProtocolError covers, and conflating
the two would put "the JSON was malformed" in the same hierarchy as "that task
id is unknown". Use :data:novaos_client_sdk.exceptions.A2A_ERROR_TYPES for
the latter.
JsonRpcRequest(method: str, params: dict[str, Any], id: Any = None)
dataclass
One well-formed JSON-RPC 2.0 request.
id is typed loosely on purpose: JSON-RPC allows a string, a number, or
null, and a server must echo back whatever it was given rather than
normalizing it.
decode_request_body(raw: bytes | str, *, max_body_bytes: int = DEFAULT_MAX_BODY_BYTES) -> tuple[Any, dict[str, Any] | None]
Decode a raw HTTP request body into a JSON value.
This is the step a caller must take before :func:parse_request when it
only has bytes off the wire, not an already-decoded object. Kept separate
from parse_request because the two can fail for different reasons that
JSON-RPC 2.0 treats differently: a body that isn't valid JSON at all is
PARSE_ERROR (-32700), while a body that decodes to valid JSON but
isn't a well-formed JSON-RPC envelope is INVALID_REQUEST (-32600,
handled by parse_request). Collapsing the two into one error code is
exactly the gap this function closes: a hand-rolled route that calls
request.json() and lets the framework's decode exception propagate
surfaces an HTTP 500 for the first case instead of a JSON-RPC error for
either.
Returns (value, error_response) with exactly one of the two non-None,
matching parse_request's contract so the two compose directly.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
raw
|
bytes | str
|
The undecoded request body, as |
required |
max_body_bytes
|
int
|
Reject bodies larger than this before attempting to
decode them. Defaults to :data: |
DEFAULT_MAX_BODY_BYTES
|
Returns:
| Type | Description |
|---|---|
Any
|
|
dict[str, Any] | None
|
|
tuple[Any, dict[str, Any] | None]
|
body could not be parsed, so no request id is knowable. |
error_response(request_id: Any, code: int, message: str, data: Any = None) -> dict[str, Any]
Build a JSON-RPC 2.0 error response.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
request_id
|
Any
|
The |
required |
code
|
int
|
JSON-RPC or A2A error code |
required |
message
|
str
|
Short human-readable description |
required |
data
|
Any
|
Optional additional detail. Omitted entirely when |
None
|
Returns:
| Type | Description |
|---|---|
dict[str, Any]
|
A JSON-RPC 2.0 response object. |
parse_request(body: Any) -> tuple[JsonRpcRequest | None, dict[str, Any] | None]
Validate a decoded JSON body as a JSON-RPC 2.0 request.
Returns (request, error_response) with exactly one of the two non-None,
so a caller dispatches on whichever it got rather than on a raised
exception — the envelope is the transport's business, and a malformed one is
an ordinary response to send, not an exceptional condition.
id is read before anything else is validated, so an otherwise-invalid
request still gets its id echoed back and the caller can correlate the
failure. Batch requests (a JSON array at the top level) are rejected as
INVALID_REQUEST: A2A defines no batched operation, and silently
processing only the first element would lose calls.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
body
|
Any
|
An already-JSON-decoded request body |
required |
Returns:
| Type | Description |
|---|---|
tuple[JsonRpcRequest | None, dict[str, Any] | None]
|
|
parse_response(body: Any) -> tuple[Any, dict[str, Any] | None]
Split a JSON-RPC 2.0 response into (result, error).
Exactly one of the two is meaningful, and the error is what the caller
must check first: JSON-RPC allows a response to carry "result": null
legitimately, so "result is None" does not mean "this failed".
Returns the error as a plain dict rather than raising, mirroring
:func:parse_request's shape and for the same reason — the envelope is the
transport's business, and which exception an A2A error code deserves is
:func:novaos_client_sdk.exceptions.a2a_error_from_json_rpc's decision, not
this module's.
A bare, un-enveloped body is accepted as a result. A hand-rolled pod
server that answers with the payload alone should not be silently
unreadable — the same tolerance
:func:novaos_client_sdk.a2a_events.stream_frame_from_dict extends to bare
stream frames, and the same one the platform's TypeScript caller
extends in parseTaskResponse.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
body
|
Any
|
An already-JSON-decoded response body. |
required |
Returns:
| Type | Description |
|---|---|
tuple[Any, dict[str, Any] | None]
|
|
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
request_envelope(method: str, params: dict[str, Any], request_id: str) -> dict[str, Any]
Build a JSON-RPC 2.0 request for an A2A method call.
request_id is required rather than generated here so the caller keeps
the value it will correlate the response against — and so this module stays
free of uuid4(), which would make every envelope untestable without
patching.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
method
|
str
|
One of the |
required |
params
|
dict[str, Any]
|
The method's request object. Always a named-field object — JSON-RPC's positional (array) form is not used by any A2A operation. |
required |
request_id
|
str
|
Correlation id, echoed back verbatim by the peer. |
required |
Returns:
| Type | Description |
|---|---|
dict[str, Any]
|
A JSON-RPC 2.0 request object, ready to serialize. |
send_message_result(task: dict[str, Any] | None = None, message: Any = None) -> dict[str, Any]
Wrap a Task or a Message as A2A's SendMessageResponse oneof.
Exactly one of the two must be supplied — a protobuf oneof has exactly one member set, and emitting both would be rejected by a strict parser.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
task
|
dict[str, Any] | None
|
A serialized |
None
|
message
|
Any
|
A serialized |
None
|
Returns:
| Type | Description |
|---|---|
dict[str, Any]
|
The |
Raises:
| Type | Description |
|---|---|
ValueError
|
If both or neither payload is supplied. |
success_response(request_id: Any, result: Any) -> dict[str, Any]
Build a JSON-RPC 2.0 success response.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
request_id
|
Any
|
The |
required |
result
|
Any
|
The method's result value |
required |
Returns:
| Type | Description |
|---|---|
dict[str, Any]
|
A JSON-RPC 2.0 response object. |
novaos_client_sdk.a2a_extensions
A2A protocol extensions: declaration, negotiation and dispatch.
A2A lets an agent do more than the base protocol describes without forking it.
An extension is a URI-identified agreement between a caller and an agent —
the agent declares it on its Agent Card, the caller activates it with a
request header, and whatever data the agreement carries travels in the request's
metadata map under the extension's own URI. This module is all three halves
of that, and nothing more: it knows how to declare, negotiate and dispatch
extensions, and it knows nothing about any particular one.
Why the SDK needs it. The migration's design analysis
found zero references to "extension"
anywhere in this SDK — the mechanism did not exist, so the user-context/v1
extension chosen for secret delivery had
nothing to be built on. An earlier stage landed the read side (:class:AgentExtension and
the AgentCard.extension* accessors); this module is the half that acts on
a declaration.
Open for extension, closed for modification (an explicit OCP
requirement of this design). Adding an extension must never mean editing this file. So the
core here dispatches through :class:A2AExtension, and a new extension is a new
class in a new module that sets a uri and overrides at most two methods.
tests/test_a2a_extensions.py::TestOpenClosedPrinciple is the proof: it
defines a whole extension inside the test module and drives it end to end —
declaration, header, data plane — against unmodified core code. The
novaos.platform/extensions/user-context/v1 extension is the first real one and is
expected to land the same way.
The three halves, and who calls which.
- Declaration — an agent's card advertises what it understands.
:meth:
ExtensionRegistry.declarationsproduces thecapabilities.extensions[]array. NovaOS pods do not serve their own card (pods are not internet-reachable; the gateway serves it on their behalf), so the array is published by whoever builds the card, and :meth:novaos_client_sdk.agent_builder.AgentBuilder.declared_extensionsis where a pod hands it over. - Negotiation — the
A2A-Extensionsrequest header carries the URIs a caller wants active. :meth:ExtensionRegistry.request_headersis the calling side, :meth:ExtensionRegistry.activatethe serving side, and :class:ExtensionActivationis the outcome both read. - Dispatch — an activated extension's data arrives at
metadata[<uri>]; :meth:ExtensionActivation.valueroutes it to the extension that owns the URI and returns whatever that extension makes of it.
Activation is not authorisation, and it is not encryption. This is
explicit by design: extension data is plain JSON in the request body, visible to
exactly the same infrastructure a header would be. The protocol provides no
confidentiality for it, and neither does this module — an extension that carries
secrets is responsible for encrypting them before
:meth:A2AExtension.request_value returns them, which is why that
work is handled by a separate sealed-envelope mechanism rather than treating it as part of this mechanism.
Unknown extensions are ignored, not refused. A caller may ask for anything;
an agent activates the intersection with what it implements and says so in its
response. The one hard failure is the mirror image — an extension the agent
declared required that the caller did not activate — which A2A answers
-32008 (:class:~novaos_client_sdk.exceptions.ExtensionSupportRequiredError).
Design notes: the migration's SDK gap analysis, the
extension decision, and the spec-version pin (see a2a_types.A2A_SPEC_VERSION)
are documented in the migration's architecture decision record.
A2AExtension
One A2A protocol extension — the class a new extension subclasses.
This is the seam that keeps the mechanism closed for modification.
Everything the core needs to know about an extension is asked of this class,
so adding one is writing a subclass, not editing
:class:ExtensionRegistry. A minimal extension is four lines::
class TraceContextExtension(A2AExtension):
uri = "https://example.com/extensions/trace-context/v1"
description = "Propagates the caller's trace context."
Every method has a working default, deliberately. Some extensions carry no
data at all — activating them is the whole agreement — and those should not
have to implement a data plane to say so. An extension that does carry data
overrides :meth:request_value (what the caller publishes) and
:meth:read_value (what the agent makes of it), which are the two ends of
the same wire.
Attributes are class-level because they describe the extension, not an
instance of it; :meth:params is a method rather than an attribute because a
declaration's params can legitimately depend on how the instance was
configured (a key id, an endpoint, a supported-algorithms list).
declaration() -> AgentExtension
This extension as the Agent Card declares it.
Returns:
| Type | Description |
|---|---|
AgentExtension
|
The |
params() -> dict[str, Any] | None
Extension-specific configuration to publish in the declaration.
Returns:
| Type | Description |
|---|---|
dict[str, Any] | None
|
A JSON object for |
dict[str, Any] | None
|
none. The default publishes none. |
read_value(value: Any) -> Any
Interpret the value a caller published at metadata[uri].
The serving half of the data plane, and the dispatch target: the core
finds which extension owns a URI, and this decides what its payload
means. An extension that encrypts in :meth:request_value decrypts
here.
This is called from :meth:ExtensionActivation.value, on demand rather
than during negotiation, so an extension whose reading can fail (a bad
decryption, a malformed payload) raises where a skill can attribute it,
not from inside the request handler's dispatch path.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
value
|
Any
|
The raw value found in the request metadata, or |
required |
Returns:
| Type | Description |
|---|---|
Any
|
Whatever the extension makes of it. The default returns it |
Any
|
unchanged, which is right for an extension whose payload is already |
Any
|
the value a skill wants. |
request_value() -> Any
The value to publish at metadata[uri] on an outbound request.
The calling half of the data plane. Called once per request by
:meth:ExtensionRegistry.request_metadata, so an extension whose payload
is per-run (a token, a bundle of encrypted secrets) can return live data
rather than something frozen at construction.
Whatever this returns is plain JSON on the wire. The A2A extension mechanism provides no confidentiality (ADR F7/G1), so an extension carrying anything sensitive must encrypt it here, not assume the transport did.
Returns:
| Type | Description |
|---|---|
Any
|
A JSON-serializable value, or |
Any
|
default publishes nothing, which is right for an extension whose |
Any
|
activation is the entire agreement. |
ExtensionActivation(*, activated: Iterable[str] = (), requested: Iterable[str] = (), ignored: Iterable[str] = (), unmet_required: Iterable[str] = (), registry: ExtensionRegistry | None = None, metadata: Iterable[Any] = ())
The outcome of one A2A-Extensions negotiation.
Produced by :meth:ExtensionRegistry.activate on the serving side and
:meth:ExtensionRegistry.negotiate_card on the calling side. The same four
lists describe both directions, read from the point of view of whoever asked
for the negotiation:
- :attr:
activated— understood by both sides, and in force. - :attr:
requested— everything the caller asked for, as it arrived. - :attr:
ignored— asked for but not implemented here. Not an error: A2A says an agent activates what it can and gets on with the request. - :attr:
unmet_required— declaredrequiredbut not activated. This is an error, and :meth:unmet_requirement_reasonwords it.
Instances are immutable in practice — the lists are exposed as tuples — but
the object is not a frozen dataclass, because it also carries the request's
metadata maps so that :meth:value can dispatch without the caller
re-supplying them.
Build an activation. Normally produced by an ExtensionRegistry.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
activated
|
Iterable[str]
|
URIs in force for this request. |
()
|
requested
|
Iterable[str]
|
URIs the caller asked for. |
()
|
ignored
|
Iterable[str]
|
Requested URIs this side does not implement. |
()
|
unmet_required
|
Iterable[str]
|
Declared-required URIs that were not activated. |
()
|
registry
|
ExtensionRegistry | None
|
The registry to dispatch :meth: |
None
|
metadata
|
Iterable[Any]
|
The request's metadata maps, most authoritative first. |
()
|
activated: tuple[str, ...]
property
URIs in force for this request, sorted.
ignored: tuple[str, ...]
property
Requested URIs this side does not implement, sorted.
requested: tuple[str, ...]
property
URIs the caller asked to activate, sorted.
unmet_required: tuple[str, ...]
property
Declared-required URIs the caller did not activate, sorted.
header_value() -> str
The activated URIs as one A2A-Extensions header value.
is_active(uri: str) -> bool
Is this extension in force for the request?
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
uri
|
str
|
The extension's URI, matched exactly. |
required |
response_headers() -> dict[str, str]
Headers reporting what this request actually activated.
A2A's negotiation is two-way: a caller says what it wants and the agent answers with what is in force, so a caller that asked for three extensions and got one knows which one without inferring it from the response body.
Returns:
| Type | Description |
|---|---|
dict[str, str]
|
|
dict[str, str]
|
dict** otherwise — an empty header line would claim a negotiation |
dict[str, str]
|
happened and produced nothing, which is not the same as an agent |
dict[str, str]
|
that has no extensions at all. |
unmet_requirement_reason() -> str | None
Why this request cannot be served as it stands, or None.
Returns a message rather than raising, matching
:func:novaos_client_sdk.a2a_types.unsupported_version_reason: the
caller decides how to frame it, which on the JSON-RPC binding means a
-32008 error object
(:class:~novaos_client_sdk.exceptions.ExtensionSupportRequiredError).
Returns:
| Type | Description |
|---|---|
str | None
|
A human-readable reason naming the missing URIs, or |
str | None
|
every required extension was activated. |
value(uri: str) -> Any
The data an activated extension's caller published, interpreted.
Dispatches to :meth:A2AExtension.read_value on the extension that owns
uri — this is the mechanism's dispatch step, and the reason a skill
never has to know which metadata map a payload arrived in.
The metadata maps are searched most-authoritative first, the same rule
and the same order
:func:novaos_client_sdk.agent_context.novaos_context_from_metadata
uses for the NovaOS context: A2A gives a caller two places to attach
metadata to a SendMessage (the request params and the message
itself), and neither placement should be a silent no-op.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
uri
|
str
|
The extension's URI. |
required |
Returns:
| Type | Description |
|---|---|
Any
|
|
Any
|
Otherwise whatever its :meth: |
Any
|
including for an activated extension that published no data, since |
Any
|
"activated but silent" is a state an extension may legitimately give |
Any
|
meaning to. |
ExtensionRegistry(extensions: Iterable[A2AExtension] = ())
The extensions one side of a conversation implements.
Both roles use the same container, because both sides of an extension are the same set of agreements read in opposite directions:
- an agent registers what it understands, publishes
:meth:
declarationson its card, and calls :meth:activateper request; - a caller registers what it intends to use, sends
:meth:
request_headersand :meth:request_metadata, and may check itself against an agent's card with :meth:negotiate_cardfirst.
A registry is a plain object with no global state. There is deliberately no module-level default registry to import: process-wide mutable registration makes what an agent declares depend on which modules happened to be imported, and an agent's declared surface should be something its construction states outright.
Build a registry over extensions.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
extensions
|
Iterable[A2AExtension]
|
Extension instances to register, in order. |
()
|
Raises:
| Type | Description |
|---|---|
ValueError
|
If an extension has no |
required_uris: list[str]
property
The URI of every registered extension marked required.
uris: list[str]
property
Every registered URI, in registration order.
activate(headers: Any, *metadata: Any) -> ExtensionActivation
Negotiate an inbound request's A2A-Extensions header.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
headers
|
Any
|
The request's headers, any mapping with |
required |
*metadata
|
Any
|
The request's metadata maps, most authoritative first —
for |
()
|
Returns:
| Type | Description |
|---|---|
ExtensionActivation
|
What is in force, what was ignored, and what is missing. A request |
ExtensionActivation
|
asking for an extension this agent does not implement is not an |
ExtensionActivation
|
error — it lands in :attr: |
ExtensionActivation
|
request proceeds. |
declarations() -> list[AgentExtension]
The capabilities.extensions[] array for an Agent Card.
The declaration half of the mechanism. What publishes it differs by deployment — the gateway serves a NovaOS pod's card, the Galaxy scaffold generates card content at build time — so this returns the array rather than writing it anywhere.
get(uri: str) -> A2AExtension | None
The registered extension with this URI, or None.
negotiate_card(card: AgentCard) -> ExtensionActivation
Check what this registry and card's agent actually agree on.
The calling side's mirror of :meth:activate, and worth running before
a call rather than after a failure: it is how a caller learns that the
agent requires an extension it cannot speak — a condition that otherwise
surfaces as a -32008 from the first request, and one no retry fixes.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
card
|
AgentCard
|
The agent's Agent Card, as
:meth: |
required |
Returns:
| Type | Description |
|---|---|
ExtensionActivation
|
attr: |
ExtensionActivation
|
implemented here, which is exactly what to send; |
ExtensionActivation
|
attr: |
ExtensionActivation
|
declared by this agent, so sending them would be noise; |
ExtensionActivation
|
attr: |
ExtensionActivation
|
and not implemented here, which is the call that cannot succeed. |
ExtensionActivation
|
A card carries no request data, so this result is deliberately not |
ExtensionActivation
|
bound to a registry and :meth: |
ExtensionActivation
|
always |
register(extension: A2AExtension) -> A2AExtension
Register one extension and return it.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
extension
|
A2AExtension
|
The extension to add. |
required |
Returns:
| Type | Description |
|---|---|
A2AExtension
|
The extension, so a call site can register and keep a reference in |
A2AExtension
|
one expression. |
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
request_headers() -> dict[str, str]
Headers activating every registered extension.
Returns:
| Type | Description |
|---|---|
dict[str, str]
|
|
dict[str, str]
|
registered — so a client with no extensions sends no header rather |
dict[str, str]
|
than an empty one. |
request_metadata() -> dict[str, Any]
The metadata every registered extension wants to publish.
Each extension's :meth:A2AExtension.request_value is placed under its
own URI, which is where A2A puts extension data and where the serving
side's :meth:ExtensionActivation.value looks for it. An extension
returning None contributes no key at all, rather than a null a
peer would have to distinguish from an absent one.
Returns:
| Type | Description |
|---|---|
dict[str, Any]
|
A metadata fragment to merge into the outbound request. |
extensions_header_value(uris: Iterable[str]) -> str
Render extension URIs as one A2A-Extensions header value.
Deduplicated and sorted, matching the reference implementation's
with_a2a_extensions. Sorting is not cosmetic: it makes the header a
function of the set of extensions rather than of the order they happened to
be registered in, so a request is byte-identical across runs and a test can
assert on it without pinning registration order.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
uris
|
Iterable[str]
|
Extension URIs. Blank entries are dropped. |
required |
Returns:
| Type | Description |
|---|---|
str
|
A comma-separated header value, or |
str
|
which the caller should treat as "send no header at all", since an empty |
str
|
header line is a request to activate nothing, spelled the long way. |
requested_extension_uris(headers: Any) -> set[str]
Return every extension URI headers asks to activate.
Reads the header the two ways HTTP allows a caller to send a list — repeated
header lines, and one line of comma-separated values — because a caller may
use either and both mean the same thing (RFC 9110 §5.3). Empty items are
dropped rather than activating an extension named "".
Header lookup is case-insensitive per RFC 9110 §5.1, matching
:func:novaos_client_sdk.a2a_types.requested_protocol_version.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
headers
|
Any
|
Any mapping with |
required |
Returns:
| Type | Description |
|---|---|
set[str]
|
The requested URIs, deduplicated. Empty when the header is absent — |
set[str]
|
which is the ordinary case, since most calls activate nothing. |
Task and push-notification persistence
The stores and helpers AgentBuilder uses by default to track a task's lifecycle and any registered push-notification webhooks; swap in your own by implementing the same interface.
novaos_client_sdk.a2a_task_store
Task persistence for the NovaOS client SDK.
Fifth module of the A2A type family, and the first that is not a wire type.
:mod:novaos_client_sdk.a2a_task models what a Task is; this module
models where one lives between the moment :class:~novaos_client_sdk.agent_builder.AgentBuilder
creates it and the moment GetTask, CancelTask, ListTasks or
SubscribeToTask needs to find it again.
Why this had no reason to exist before now. Earlier in the A2A migration,
every call built a Task and handed it straight back on the same call stack
that created it — there was never a second call that needed to find the same
object again. SendMessage still works that way for a caller that never
touches the store (see :class:~novaos_client_sdk.agent_builder.AgentBuilder's
acceptance criterion that its response is byte-identical either way); what
changes is that the task now also has somewhere to be looked up afterward.
Scope, per the ADR this ticket implements (F9/F15). NovaOS pods are not
internet-reachable and the Temporal worker drives execution; the gateway owns
the authoritative run record the Agent Log Panel and the Loki pipeline key on.
So an in-process store is the right default — a pod's lifetime is one
execution in the common case — and a durable, cross-pod store is explicitly
not what this module is for: duplicating the gateway's run record would
create a second, competing source of truth for run status. The reference A2A
SDK's DB-backed store, its Alembic migrations, and a2a_db_cli are
deliberately not ported here; a client SDK does not take on a database
dependency to answer GetTask.
InMemoryTaskStore(max_tasks: int = DEFAULT_MAX_TASKS)
Bases: TaskStore
A :class:TaskStore backed by a dict in this process's memory.
The default for :class:~novaos_client_sdk.agent_builder.AgentBuilder, and
per the ADR (F9/F15) the only store this SDK ships: a pod is not
internet-reachable and the gateway already owns the durable run record, so
there is nothing for a second, cross-pod-durable implementation to do here
that would not compete with that record.
Eviction policy. Bounded by max_tasks (default
:data:DEFAULT_MAX_TASKS), tracked in least-recently-saved order. Every
:meth:save moves that task to the most-recently-saved end; once the
store holds more than max_tasks entries, the least-recently-saved one
is evicted — on the theory that a task nobody has updated in a while is
also the task nobody is still polling. This is a straight LRU-by-write
policy, not a TTL: there is no background sweep, so an idle pod holds no
timer and eviction only ever happens as a side effect of a new
:meth:save. A pod whose lifetime is one execution (the common case)
never approaches the cap; it exists for the fleet-mode pod that stays
warm across many runs and must not retain every task it has ever handled.
Initialize the store.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
max_tasks
|
int
|
The eviction cap described above. Must be positive. |
DEFAULT_MAX_TASKS
|
delete(task_id: str) -> None
async
Remove the task stored under task_id. A no-op if there is none.
get(task_id: str) -> Task | None
async
Return the task stored under task_id, or None if there is none.
Does not count as a "save" for eviction-ordering purposes — reading
a task does not protect it from eviction, only updating one does. A
long-poller that only ever calls :meth:get on a stalled task gets no
special protection from the cap.
list_tasks() -> list[Task]
async
Return every task currently held, in this store's own (insertion/LRU) order.
A snapshot list, not a live view: the caller gets the tasks as they
stood at the moment of the call, and a later save/delete
elsewhere does not retroactively change what was already returned.
save(task: Task) -> None
async
Persist task, evicting the least-recently-saved entry if over capacity.
TaskStore
Bases: ABC
Where an :class:~novaos_client_sdk.a2a_task.Task is kept once it exists.
Three operations, matching what :class:~novaos_client_sdk.agent_builder.AgentBuilder
and the GetTask / CancelTask / ListTasks
handlers actually do to a task: write it, read it back by id, and remove it.
A subclass backing this with something other than memory — a Redis instance
shared by a fleet of pods, say — implements the same three methods and
:class:~novaos_client_sdk.agent_builder.AgentBuilder does not need to know
the difference; that substitutability is the entire reason this is an ABC
rather than :class:InMemoryTaskStore being the only option.
delete(task_id: str) -> None
abstractmethod
async
Remove the task stored under task_id. A no-op if there is none.
get(task_id: str) -> Task | None
abstractmethod
async
Return the task stored under task_id, or None if there is none.
list_tasks() -> list[Task]
abstractmethod
async
Return every task currently held, as a snapshot list.
Added once there was a reason to enumerate a store rather than only
address one task by id: ListTasks has to see everything before it
can filter and paginate any of it. :class:InMemoryTaskStore already
holds everything this needs, so extending the ABC now is a
deliberate, anticipated step — the same way :meth:save /
:meth:get / :meth:delete were sized for what this store's callers
actually do. It is not a breaking change to any real implementer:
this SDK ships exactly one concrete :class:TaskStore
(:class:InMemoryTaskStore) and there is no other known
implementation to break.
No filtering or pagination happens here — that is entirely the
JSON-RPC handler's job (:meth:~novaos_client_sdk.agent_builder.AgentBuilder's
ListTasks dispatch), matching the same store/handler division of
labour every other operation in this class already draws: the store
answers "what do you have", the handler decides "what did the caller
ask for".
save(task: Task) -> None
abstractmethod
async
Persist task, replacing any earlier task with the same id.
novaos_client_sdk.a2a_task_updater
TaskUpdater — a skill's way to report progress mid-execution.
Before this module, a skill handler was silent from the moment
:class:~novaos_client_sdk.agent_builder.AgentBuilder invoked it until the
moment its SkillResult came back: there was no way to say "still working",
attach an artifact early, or explain a pause, short of returning. This is the
user-visible half of that capability — the piece a skill author actually touches — and its
shape deliberately mirrors the reference A2A SDK's TaskUpdater: one method
per lifecycle transition, so a skill's state
changes read the same way they would against any other A2A server SDK.
This does not replace the SkillResult return contract.
:class:~novaos_client_sdk.agent_builder.AgentBuilder still decides a task's
terminal outcome from the handler's return value by default, exactly as it
did before this class existed — that is what keeps an agent that never
touches this class behaving identically. A skill still has
to return a SkillResult regardless (it is the handler's declared return
type), so the ordinary way to close a task in a given terminal state —
including :attr:~novaos_client_sdk.a2a_task.TaskState.REJECTED
— is to return one whose status maps there, not to also call
:meth:TaskUpdater.complete, :meth:TaskUpdater.failed,
:meth:TaskUpdater.reject or :meth:TaskUpdater.cancel directly.
A skill may still call one of those directly if it has its own reason to
(most concretely: it wants to close the task before it has finished building
the SkillResult it must still return). Doing so is safe and takes effect:
:meth:~novaos_client_sdk.agent_builder.AgentBuilder._finalize_task catches
the :class:RuntimeError its own later terminal call raises against an
already-sealed updater, and treats the skill's direct
call as authoritative rather than overwriting it with wherever the returned
SkillResult.status maps. See that method's docstring for the full
precedence reasoning. What a skill calls mid-execution for a non-terminal
update is :meth:start_work, :meth:update_status (with
:attr:~novaos_client_sdk.a2a_task.TaskState.WORKING), :meth:add_artifact,
:meth:requires_input and :meth:requires_auth — none of which this class
treats as terminal.
Interrupts are not terminal.
:attr:~novaos_client_sdk.a2a_task.TaskState.INPUT_REQUIRED and
:attr:~novaos_client_sdk.a2a_task.TaskState.AUTH_REQUIRED are deliberately
excluded from the states that seal a task against further updates — treating
either as terminal is the exact misclassification this design exists to prevent, and
here it would additionally mean a paused task could never be moved again once
:meth:requires_input or :meth:requires_auth was called.
TaskUpdater(store: TaskStore, event_queue: EventQueue, task_id: str, context_id: str)
Publishes one task's lifecycle transitions to its store and event queue.
Every method here does the same two things: writes the change into
store so a later GetTask sees it, and enqueues the
corresponding event onto event_queue so a live SubscribeToTask
would see it as it happens. A skill never touches either
directly — this class is the entire surface.
Initialize the updater.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
store
|
TaskStore
|
Where transitions are persisted. |
required |
event_queue
|
EventQueue
|
Where transitions are published live. |
required |
task_id
|
str
|
The A2A |
required |
context_id
|
str
|
The A2A |
required |
is_revoked: bool
property
Whether :meth:revoke has been called — see it for what that means.
is_sealed: bool
property
Will this updater accept no further writes?
True once a terminal method has succeeded through this instance,
or once :meth:revoke has been called on it — in
both cases a caller deciding whether to attempt a write should not.
The terminal half of that, in detail:
True once :meth:complete, :meth:failed, :meth:reject,
:meth:cancel, or :meth:update_status called directly with a
:data:~novaos_client_sdk.a2a_task.TERMINAL_TASK_STATES member has
written through this exact instance. From that point on, every
further call through :meth:update_status (including its named
terminal wrappers) raises :class:RuntimeError rather than silently
doing nothing — this property lets a caller that wants to know
before attempting a write check first, rather than catching that
exception (see :meth:~novaos_client_sdk.agent_builder.AgentBuilder._finalize_task,
which does both: checks this to decide whether to attempt anything
at all, and still catches the exception as a narrower backstop).
Only reflects writes made through this instance, not the store —
see :meth:_set_status's own docstring for how a different
updater sealing the same task (a concurrent CancelTask, most
concretely) is handled instead.
add_artifact(parts: list[Part], *, artifact_id: str | None = None, name: str | None = None, description: str | None = None, metadata: dict[str, Any] | None = None, append: bool = False, last_chunk: bool = False) -> None
async
Attach or extend one output artifact and publish a TaskArtifactUpdateEvent.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
parts
|
list[Part]
|
The artifact chunk's content. |
required |
artifact_id
|
str | None
|
The artifact's identity. A new one is generated when omitted; pass the same id across calls to revise or extend one artifact instead of producing a second, unrelated one. |
None
|
name
|
str | None
|
Optional human-readable name. |
None
|
description
|
str | None
|
Optional human-readable description. |
None
|
metadata
|
dict[str, Any] | None
|
Optional artifact metadata. |
None
|
append
|
bool
|
When |
False
|
last_chunk
|
bool
|
Whether this chunk completes the artifact. |
False
|
cancel(message: A2AMessage | None = None) -> None
async
Mark the task canceled. Terminal.
complete(message: A2AMessage | None = None) -> None
async
Mark the task completed. Terminal.
failed(message: A2AMessage | None = None) -> None
async
Mark the task failed. Terminal.
new_agent_message(parts: list[Part], metadata: dict[str, Any] | None = None) -> A2AMessage
Build (but do not publish) a new agent message for this task.
A convenience for a skill that wants to pass a status message to
:meth:update_status or one of its named wrappers without
re-threading task_id/context_id by hand.
reject(message: A2AMessage | None = None) -> None
async
Mark the task rejected — it was never going to work. Terminal.
requires_auth(message: A2AMessage | None = None) -> None
async
Pause the task pending caller authorization. Not terminal — interrupts never are.
requires_input(message: A2AMessage | None = None) -> None
async
Pause the task pending caller input. Not terminal — interrupts never are.
revoke() -> None
Turn every later write through this updater into a silent no-op.
Called by :meth:~novaos_client_sdk.agent_builder.AgentBuilder._supersede_live_execution
when a newer request has taken over this updater's task id: the
execution holding this instance has been preempted, and whatever it
still tries to publish — a progress update, an artifact, a terminal
state — would land on the task the newer execution now owns. The
preempted execution already checks its cancellation token before
publishing, but that check and the write are separated by awaits
(:meth:~novaos_client_sdk.agent_builder.AgentBuilder._finalize_task
reads the task, attaches artifacts, then writes the status), and a
supersede can land in between. Revocation closes that
window at the only place it can be closed: inside this instance, re-
checked under :attr:_lock immediately before each store write.
Silent rather than raising: unlike a second terminal call, which is
the skill's own mistake and gets a :class:RuntimeError, being
superseded is nothing the skill did — and by then its Task has
been cancelled anyway. One-way, like the terminal guard.
start_work(message: A2AMessage | None = None) -> None
async
Mark the task as actively running.
submit(message: A2AMessage | None = None) -> None
async
Mark the task submitted (acknowledged, not yet started).
update_status(state: TaskState, message: A2AMessage | None = None) -> None
async
Move the task to state and publish a TaskStatusUpdateEvent.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
state
|
TaskState
|
The task's new state. |
required |
message
|
A2AMessage | None
|
The agent's account of reaching this state, if any. |
None
|
Raises:
| Type | Description |
|---|---|
RuntimeError
|
The task already reached a
:data: |
novaos_client_sdk.a2a_event_queue
The transport a task's lifecycle events travel over.
:mod:novaos_client_sdk.a2a_events models the frames
(:class:~novaos_client_sdk.a2a_events.TaskStatusUpdateEvent,
:class:~novaos_client_sdk.a2a_events.TaskArtifactUpdateEvent) an in-flight
task produces; until this module, nothing in the SDK ever produced one — they
were parsed off the wire (by :class:~novaos_client_sdk.client.Client) but
never emitted, because emitting one requires something to hold a task's
in-flight events between the moment they happen and the moment a consumer
reads them. That something is :class:EventQueue.
Deliberately unbounded. The reference A2A SDK's event queue is bounded,
because it is always paired with a consumer draining it concurrently — a
streaming request handler, or a background task started for exactly that
purpose. AgentBuilder has no such consumer yet: SubscribeToTask, the
JSON-RPC method that would drain one live, landed one wave after this
one. A bounded queue with nothing to drain it would turn a skill that reports
enough progress through :class:~novaos_client_sdk.a2a_task_updater.TaskUpdater
into a SendMessage call that deadlocks on a full queue, which is worse than
the silent-progress problem this machinery exists to fix. So this queue never
blocks a producer; SubscribeToTask is what gives it a reader.
EventQueue(maxsize: int = 0)
A single task's in-flight event stream, buffered in memory.
One instance per task execution — :class:~novaos_client_sdk.agent_builder.AgentBuilder
creates a fresh queue for every SendMessage call, so two concurrent
executions for different tasks never share one, and there is no cross-task
state here to interfere between them.
Initialize the queue.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
maxsize
|
int
|
Forwarded to the underlying |
0
|
close() -> None
async
Mark the queue closed and notify every subscriber it is done.
Future :meth:enqueue_event calls become no-ops, as before. Also
puts a single None onto every queue :meth:subscribe has handed
out, waking a subscriber blocked waiting for the next event instead
of leaving it to wait forever — a producer can reach this point
without ever having enqueued a terminal event at all (an abandoned
stream, a task canceled before it finished), and a subscriber must
not assume "closed" implies "already saw a terminal frame".
Idempotent: a second call is a no-op, so it never queues a
second sentinel onto an already-notified subscriber.
Does not drain or clear whatever is already buffered on the primary
queue :meth:dequeue_event reads — a consumer that was already
reading may still finish doing so.
dequeue_event() -> TaskEvent
async
Wait for and return the next event, from this instance's one primary queue.
Unaffected by :meth:subscribe — this method's own queue is
separate from every subscriber's, so a caller here still sees every
event exactly once, exactly as before subscriber queues existed. Used by tests that
verify what :class:~novaos_client_sdk.a2a_task_updater.TaskUpdater
enqueued, and by any single-consumer caller that has no need for
:meth:subscribe's multi-consumer guarantee.
Can block forever if the producer abandons this task without ever
enqueuing a terminal event and without ever calling :meth:close
either — this method itself has no way to distinguish "nothing
has happened yet" from "nothing will ever happen again", unlike
:meth:subscribe's queue, which additionally receives a closing
sentinel. A caller that must not hang forever on an abandoned
producer should use :meth:subscribe instead.
empty() -> bool
Whether the queue currently holds no undelivered events.
enqueue_event(event: TaskEvent) -> None
async
Push event onto the queue, and fan it out to every subscriber.
Silently dropped once the queue is :meth:close-d, matching how a
closed SSE connection drops frames rather than erroring the producer —
the task itself has already reached its outcome by the time anything
closes this queue, so a straggling event has nowhere useful to go.
subscribe() -> asyncio.Queue[TaskEvent | None]
Register a new, independent subscriber queue.
Every event this instance is given from here on (via
:meth:enqueue_event) is also delivered to the returned queue, and a
single None sentinel is delivered to it once :meth:close runs —
the only way a consumer reading from it can tell "the producer is
done, and this is the last one" apart from "the producer enqueued
another real event". A queue returned here is never shared with
another caller of this method — call it once per subscriber, e.g.
once per SubscribeToTask request.
A subscriber that joins mid-stream sees only what is enqueued from
this point on, never a replay of what came before — the same
no-replay guarantee A2A's own spec gives a reconnecting
SubscribeToTask caller (spec §3.7), which is why
:class:~novaos_client_sdk.agent_builder.AgentBuilder's own use of
this method always yields the task's current snapshot from the
store first, before ever reading from the queue this returns.
If this instance is already closed, the returned queue already has
its sentinel queued, so a caller reading from it once returns
immediately with None rather than hanging.
novaos_client_sdk.a2a_push_notification
A2A TaskPushNotificationConfig types for the NovaOS client SDK.
Push notification is the A2A mechanism for learning that a task finished without holding a stream open or polling: a caller registers a webhook URL against a task, and the agent (or the gateway fronting it) posts to it when the task's status changes. The gateway already serves the four JSON-RPC methods this module's types travel over; this is the client half that was missing.
Flat, not nested — heed F20. The ADR's own description of
TaskPushNotificationConfig was v0.3-shaped: v0.3 wrapped url and
token in a nested pushNotificationConfig object alongside an outer
taskId. v1.0 merged that nested object's fields directly onto
TaskPushNotificationConfig — there is no nested config any more — and
pluralised the list method (ListTaskPushNotificationConfigs, not
ListTaskPushNotificationConfig). :class:TaskPushNotificationConfig
below models the v1.0, flat shape; tests/test_a2a_v1_conformance.py pins
that the v0.3 nested shape is rejected, the same guard F18 established for the
message envelope.
Wire format. A2A v1.0 defines this message in specification/a2a.proto
and JSON-RPC carries its proto3 JSON mapping: lowerCamelCase field names.
Reads accept the JSON-RPC binding's camelCase keys and the gRPC binding's
snake_case equivalents, and never raise — the same tolerant-read rule the
rest of the A2A type family follows: a config a caller reads back
off the wire is untrusted input, and a parser that raises on one bad field
leaves the caller unable to read the fields that did survive.
token and authentication.credentials are secrets. Ownership of
each is the webhook receiver's problem, but this SDK holds them in memory and
must never let them reach a log line. mask_pii_in_dict's key-aware
backstop (pii.py) treats any key whose name contains token or
credential as sensitive regardless of its content — added by this ticket
specifically so a caller who logs config.to_dict() via
extra={...} does not leak either value; tests/test_pii_in_logging.py
covers it. Nothing in this module logs on its own — the risk is a caller
handing the dict to :func:novaos_client_sdk.logging.get_agent_logger, not
anything this module does — so there is no logging code here to audit, only
the wire types the masking has to recognise by key name.
AuthenticationInfo(scheme: str = '', credentials: str | None = None)
dataclass
A2A AuthenticationInfo — how a receiver should authenticate a push.
credentials is a secret (a bearer token, a shared secret, …) the
receiving webhook uses to verify the call came from the agent it
registered with. It travels in the clear on this object exactly as A2A
defines it — this SDK does not seal it the way a2a_sealed_envelope
seals NovaOS's own secret delivery — so it must never be logged; see the
module docstring.
from_dict(data: Any) -> AuthenticationInfo
classmethod
Create from the A2A JSON wire shape.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
Any
|
An A2A |
required |
to_dict() -> dict[str, Any]
Convert to the A2A v1.0 JSON wire shape (proto3 JSON, camelCase).
TaskPushNotificationConfig(task_id: str = '', id: str = '', url: str = '', token: str | None = None, authentication: AuthenticationInfo | None = None, tenant: str | None = None)
dataclass
A2A TaskPushNotificationConfig (spec v1.0.1) — a task's webhook registration.
Flat (F20): url and token are fields on this object directly,
not on a nested pushNotificationConfig — see the module docstring.
id identifies this registration, distinct from task_id: a task
may have more than one config registered against it (v1.0 §3.1.7), which
is the whole reason ListTaskPushNotificationConfigs exists rather than
the caller simply remembering the one it set.
token is a secret the agent should echo back on every push so the
receiver can verify the call is not forged; authentication covers the
receiver-side check going the other way. Both must never reach a log
line — see the module docstring.
from_dict(data: Any) -> TaskPushNotificationConfig
classmethod
Create from the A2A JSON wire shape.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
Any
|
An A2A |
required |
to_dict() -> dict[str, Any]
Convert to the A2A v1.0 JSON wire shape (proto3 JSON, camelCase).
taskId, id and url are always emitted, matching how the
rest of the A2A type family (:class:novaos_client_sdk.a2a_task.Task,
for one) always emits its own REQUIRED-shaped identifier fields.
TaskPushNotificationConfigList(configs: list[TaskPushNotificationConfig] = list(), next_page_token: str | None = None)
dataclass
A2A ListTaskPushNotificationConfigsResponse (spec v1.0.1) — one page.
Mirrors :class:novaos_client_sdk.a2a_task.TaskList's pagination contract
exactly, per the ticket's own instruction to reuse that pattern rather
than invent a second one: next_page_token is the whole of it — a
non-empty value means another page exists and is fetched by passing it
back as page_token.
from_dict(data: Any) -> TaskPushNotificationConfigList
classmethod
Create from the A2A JSON wire shape.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
Any
|
A |
required |
to_dict() -> dict[str, Any]
Convert to the A2A v1.0 JSON wire shape (proto3 JSON, camelCase).
novaos_client_sdk.a2a_push_notification_store
TaskPushNotificationConfig persistence for the NovaOS client SDK.
Mirrors :mod:novaos_client_sdk.a2a_task_store deliberately closely: same
ABC/in-memory split, same asyncio.Lock-guarded OrderedDict, same
LRU-by-write eviction policy. The client side of A2A's push notification
config methods needed somewhere to send a
:class:~novaos_client_sdk.a2a_push_notification.TaskPushNotificationConfig
to; this is where an agent pod holds one once it arrives, so
CreateTaskPushNotificationConfig has something for
GetTaskPushNotificationConfig / ListTaskPushNotificationConfigs /
DeleteTaskPushNotificationConfig to read back.
Keyed by (task_id, config_id), not by config_id alone. A2A allows
more than one registration per task (spec §3.1.7) — that is the entire reason
ListTaskPushNotificationConfigs exists rather than a caller remembering the
one config it set — so a config's identity on this side is the pair, matching
what every one of the four wire methods actually addresses a config by
(:class:~novaos_client_sdk.client.Client's get_task_push_notification_config
and delete_task_push_notification_config both take task_id and
config_id).
Scope, same as the task store (ADR F9/F15). An in-process store is the
right default for the same reason InMemoryTaskStore is: a pod is not
internet-reachable, and there is no gateway-owned durable record for push
notification config the way there is for run status — so there is nothing for
a cross-pod-durable implementation to compete with. A pod that wants push
notification config to survive a restart injects its own
:class:PushNotificationConfigStore implementation; this SDK ships only the
in-memory one, matching the task store's own single-implementation posture.
This module records; it does not deliver. A config saved
here is held for the platform to act on — nothing in this SDK ever dials the
callback url a config carries. Delivering the actual webhook, where a
deployment does that at all, is the platform gateway's job, not
this store's or this agent's. A caller-supplied url is still validated
against the same SSRF guard used for agent discovery
(:func:~novaos_client_sdk.dns.validate_agent_url) before Create ever
reaches :meth:PushNotificationConfigStore.save — see
:meth:~novaos_client_sdk.agent_builder.AgentBuilder._dispatch_push_notification_config —
so a config resolving to a private, loopback or link-local address is
rejected at registration rather than merely never dialled.
InMemoryPushNotificationConfigStore(max_configs: int = DEFAULT_MAX_PUSH_CONFIGS)
Bases: PushNotificationConfigStore
A :class:PushNotificationConfigStore backed by a dict in this process's memory.
The default (and, per ADR F9/F15, the only) store this SDK ships — see the module docstring.
Eviction policy. Identical to
:class:~novaos_client_sdk.a2a_task_store.InMemoryTaskStore's: bounded by
max_configs (default :data:DEFAULT_MAX_PUSH_CONFIGS), tracked in
least-recently-saved order. Every :meth:save moves that (task_id,
config_id) entry to the most-recently-saved end; once the store holds
more than max_configs entries, the least-recently-saved one is evicted.
A straight LRU-by-write policy, not a TTL — no background sweep, eviction
only ever happens as a side effect of a new :meth:save.
Initialize the store.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
max_configs
|
int
|
The eviction cap described above. Must be positive. |
DEFAULT_MAX_PUSH_CONFIGS
|
delete(task_id: str, config_id: str) -> None
async
Remove the config stored under (task_id, config_id). A no-op if there is none.
get(task_id: str, config_id: str) -> TaskPushNotificationConfig | None
async
Return the config stored under (task_id, config_id), or None.
Does not count as a "save" for eviction-ordering purposes, matching
:meth:~novaos_client_sdk.a2a_task_store.InMemoryTaskStore.get.
list(task_id: str) -> list[TaskPushNotificationConfig]
async
Return every config registered against task_id, in store order.
Scoped strictly to task_id — a config registered against a
different task must never leak into this task's page, which is the
entire reason the store key is the (task_id, config_id) pair
rather than config_id alone.
save(config: TaskPushNotificationConfig) -> None
async
Persist config, evicting the least-recently-saved entry if over capacity.
PushNotificationConfigStore
Bases: ABC
Where a :class:~novaos_client_sdk.a2a_push_notification.TaskPushNotificationConfig is kept once it exists.
Four operations, matching what
:class:~novaos_client_sdk.agent_builder.AgentBuilder's four
*TaskPushNotificationConfig handlers actually do to a registration:
write it, read one back by (task_id, config_id), list every
registration for a task, and remove one. An ABC for the same reason
:class:~novaos_client_sdk.a2a_task_store.TaskStore is: a subclass backing
this with something other than memory implements the same four methods and
AgentBuilder does not need to know the difference.
delete(task_id: str, config_id: str) -> None
abstractmethod
async
Remove the config stored under (task_id, config_id). A no-op if there is none.
get(task_id: str, config_id: str) -> TaskPushNotificationConfig | None
abstractmethod
async
Return the config stored under (task_id, config_id), or None.
list(task_id: str) -> list[TaskPushNotificationConfig]
abstractmethod
async
Return every config registered against task_id, in store order.
No pagination here — that is the JSON-RPC handler's job
(ListTaskPushNotificationConfigs), the same store/handler division
of labour :meth:~novaos_client_sdk.a2a_task_store.TaskStore.list_tasks
draws for tasks.
save(config: TaskPushNotificationConfig) -> None
abstractmethod
async
Persist config, replacing any earlier config with the same
(task_id, id).
Authentication and configuration
novaos_client_sdk.auth
Authentication providers for the NovaOS Client SDK.
APIKeyAuth(api_key: str, header_name: str = 'X-API-Key')
Bases: AuthProvider
API key authentication provider.
Formats an API key into a custom header (default: X-API-Key: <key>).
The SDK is NOT responsible for creating or validating API keys. The application must provide the API key, and the SDK formats it into headers.
Example
Note
API keys are static and can be stored in environment variables for
service-to-service authentication. Use get_api_key_from_env() helper
for convenience.
Initialize API key authentication.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
api_key
|
str
|
API key to use for authentication. |
required |
header_name
|
str
|
Name of the HTTP header to use (default: "X-API-Key"). |
'X-API-Key'
|
api_key: str
property
Get the current API key (read-only for debugging/logging).
header_name: str
property
Get the header name used for the API key.
get_headers() -> dict[str, str]
Get API key authentication headers.
Returns:
| Type | Description |
|---|---|
dict[str, str]
|
Dictionary with API key header (e.g., |
AuthProvider
Bases: ABC
Abstract base class for authentication providers.
All authentication providers must implement this interface to provide authentication headers for HTTP requests.
Example
get_headers() -> dict[str, str]
abstractmethod
Get authentication headers for HTTP requests.
Returns:
| Type | Description |
|---|---|
dict[str, str]
|
Dictionary of HTTP headers to include in requests. |
dict[str, str]
|
Should include authentication credentials in the appropriate format. |
BearerTokenAuth(token: str)
Bases: AuthProvider
Bearer token authentication provider.
Formats a Bearer token into the standard Authorization: Bearer <token> header.
The SDK is NOT responsible for creating, obtaining, or managing token lifecycle. The application must provide the token, and the SDK formats it into headers.
Example
from novaos_client_sdk.auth import BearerTokenAuth
from novaos_client_sdk.client import Client
# Application provides token (e.g., from OAuth flow)
token = my_app.get_access_token()
# SDK formats and uses it
auth = BearerTokenAuth(token=token)
client = Client(base_url="http://localhost:8000", auth=auth)
Note
Bearer tokens are dynamic and should be provided by the application at runtime.
Do NOT store Bearer tokens in environment variables or .env files.
Initialize Bearer token authentication.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
token
|
str
|
Bearer token to use for authentication. The token should be provided by the application (e.g., from OAuth flow). |
required |
token: str
property
writable
Get the current Bearer token (read-only for debugging/logging).
get_headers() -> dict[str, str]
Get Bearer token authentication headers.
Returns:
| Type | Description |
|---|---|
dict[str, str]
|
Dictionary with |
CredentialService
Bases: ABC
Async credential seam for
:class:novaos_client_sdk.auth_interceptor.AuthInterceptor.
AuthProvider above is synchronous and read once, at Client
construction — deliberately: see its module docstring's "The SDK is NOT
responsible for creating, obtaining, or managing token lifecycle." That
remains true here. What AuthProvider cannot do is hand back a
different credential on a later call, which is exactly what a
client-credentials OAuth2 token needs once it is close to expiring. This
class is the seam that adds that, without touching AuthProvider at
all: :class:~novaos_client_sdk.auth_interceptor.AuthInterceptor prefers a
CredentialService when one is configured and falls back to the
constructor's AuthProvider otherwise, so nothing already built against
AuthProvider has to change.
The application still owns obtaining the credential — refreshing an OAuth2 token, say. This class only adds a hook the application can refresh through; it does not implement any refresh itself.
Example
class LogtoTokenService(CredentialService):
def __init__(self, logto_client) -> None:
self._logto_client = logto_client
async def get_credentials(self, scheme_name, context):
# The application's own refresh logic decides whether the
# cached token is still good; the SDK never sees that logic.
return await self._logto_client.get_access_token()
get_credentials(scheme_name: str, context: ClientCallContext) -> str | dict[str, str] | None
abstractmethod
async
Return the credential for scheme_name, or None if this
service has nothing for it.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
scheme_name
|
str
|
The Agent Card |
required |
context
|
ClientCallContext
|
This call's
:class: |
required |
Returns:
| Type | Description |
|---|---|
str | dict[str, str] | None
|
A bare credential value (a bearer token, an API key) for |
str | dict[str, str] | None
|
|
str | dict[str, str] | None
|
declared shape; or a ready-made |
str | dict[str, str] | None
|
unchanged, which is how a wrapped synchronous |
str | dict[str, str] | None
|
answers, since it already knows its own header shape; or |
str | dict[str, str] | None
|
to say this service has nothing for this scheme, which |
str | dict[str, str] | None
|
|
str | dict[str, str] | None
|
|
InMemoryContextCredentialStore()
Bases: CredentialService
Fixed credentials, keyed by session id and then by scheme name.
Matches the reference a2a-python
InMemoryContextCredentialStore (src/a2a/client/auth/credentials.py):
a lookup table an application populates itself, rather than a service that
fetches or refreshes anything. It exists for the common case where the
application already has a per-session token — from its own OAuth2 flow —
and needs no more than a place to hand it to
:class:~novaos_client_sdk.auth_interceptor.AuthInterceptor. An
application whose credential genuinely changes without being told (a
background refresh loop) should implement :class:CredentialService
directly instead of trying to keep this store's contents current.
The session id comes from context.state["session_id"] — a convention
this store alone reads; AuthInterceptor passes its context through
unopened — and defaults to "" when a call's context sets none, so a
single caller that never populates session_id still gets one shared
credential per scheme rather than a KeyError.
get_credentials(scheme_name: str, context: ClientCallContext) -> str | dict[str, str] | None
async
Return the stored credential for this call's session and scheme, or
None if nothing was stored for either.
set_credentials(session_id: str, scheme_name: str, credential: str) -> None
Store credential for scheme_name, under session_id.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
session_id
|
str
|
Matches |
required |
scheme_name
|
str
|
The Agent Card |
required |
credential
|
str
|
The bare credential value — see
:meth: |
required |
get_api_key_from_env(env_var: str = 'ACP_API_KEY', header_name: str = 'X-API-Key') -> APIKeyAuth
Get API key from environment variable and create APIKeyAuth instance.
This is a convenience helper for API keys only. Bearer tokens should NOT be stored in environment variables and should be provided by the application at runtime.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
env_var
|
str
|
Name of the environment variable containing the API key (default: "ACP_API_KEY"). |
'ACP_API_KEY'
|
header_name
|
str
|
Name of the HTTP header to use (default: "X-API-Key"). |
'X-API-Key'
|
Returns:
| Type | Description |
|---|---|
APIKeyAuth
|
APIKeyAuth instance configured with the API key from environment. |
Raises:
| Type | Description |
|---|---|
ValueError
|
If the environment variable is not set or is empty. |
Example
Note
This helper is for API keys only. Bearer tokens are dynamic and should be provided by the application at runtime, not from environment variables.
novaos_client_sdk.auth_interceptor
AuthInterceptor — per-call credential resolution.
Separate module from :mod:novaos_client_sdk.auth on purpose, the same way
the reference a2a-python splits client/auth/interceptor.py from
client/auth/credentials.py: this class subclasses
:class:novaos_client_sdk.interceptors.ClientCallInterceptor for real, so
importing it pulls in the rest of the package.
:class:novaos_client_sdk.auth.CredentialService does not need that and stays
free of it — see the TYPE_CHECKING note on its import in auth.py —
which is what lets tests/test_auth.py keep unit-testing the credential
seam without the full package import chain, while this module and its
Client wiring are exercised through the ordinary package-level imports the
rest of the test suite already uses.
AuthInterceptor(*, credential_service: CredentialService | None = None, auth: AuthProvider | None = None)
Bases: ClientCallInterceptor
Resolves and attaches per-call credentials.
Resolution rule, in order — deliberately simple, per the ticket:
- If a :class:
~novaos_client_sdk.auth.CredentialServicewas configured, pick the scheme to ask it for by matching the call's :class:~novaos_client_sdk.a2a_agent_card.AgentCardsecuritySchemesagainst itssecurityRequirements— see :meth:_select_scheme— and callget_credentials(scheme_name, context). - If that returned a credential, format it into a header using the
matched scheme's declared shape — see :meth:
_format_header— unless the service already returned a ready-made{header: value}mapping, which is used unchanged. - Otherwise (no
CredentialServiceconfigured, no card to read a scheme from, or the service returned nothing), fall back to the constructor's synchronousAuthProvider— exactly what :class:~novaos_client_sdk.client.Clientdid before this ticket, so a caller who supplies onlyauth=and noCredentialServicesees no change at all.
This interceptor implements before only; the default no-op after
from :class:~novaos_client_sdk.interceptors.ClientCallInterceptor is
inherited unchanged. A caller wanting a refresh-and-retry policy on a 401
builds it from after observing the error — this class does not attempt
one itself.
Initialize the interceptor.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
credential_service
|
CredentialService | None
|
Resolves a fresh credential per call. Tried first when configured. |
None
|
auth
|
AuthProvider | None
|
The same synchronous provider a caller could pass straight
to |
None
|
before(args: BeforeArgs) -> None
async
Attach the resolved credential's header(s) to args.http_kwargs.
novaos_client_sdk.interceptors
Client-side call interception seam.
Why this exists. :class:novaos_client_sdk.client.Client used to have exactly
one place a caller could act on a request or a response: the
:class:~novaos_client_sdk.auth.AuthProvider passed to the constructor, read
once, at construction (see Client.__init__'s docstring). That is enough for
a static credential, but not for one that needs to change mid-session — a
client-credentials OAuth2 token expires, and there is no hook to refresh it
short of throwing away the Client (and its connection pool) and building a
new one. This module is that hook, generalized: any cross-cutting behaviour a
caller wants to run around every request — not just auth — gets one seam
instead of one bespoke constructor argument per concern.
Mirrors the reference a2a-python client (src/a2a/client/interceptors.py,
src/a2a/client/client.py's ClientCallContext), trimmed to what this
SDK's JSON-RPC-shaped client needs: the reference's interceptor sees a typed
protobuf request object, where this one sees the (method, params) pair
:class:~novaos_client_sdk.client.Client already builds for the wire.
One shared execution path, not ten hand-rolled ones.
:meth:novaos_client_sdk.client.Client._execute and
:meth:~novaos_client_sdk.client.Client._execute_stream are the only two
places that walk the interceptor list — every operation the client exposes
routes through one of the two, so a new operation cannot forget to run
interceptors and two operations cannot silently disagree on when before
runs relative to after.
AfterArgs(method: str, params: dict[str, Any], result: Any, error: Exception | None, card: AgentCard | None, context: ClientCallContext)
dataclass
What :meth:ClientCallInterceptor.after sees, once one call — or, for a
streaming call, one frame of it — has answered.
Exactly one of result / error is meaningful for a given call:
error is None on success and the exception that was raised
otherwise, already mapped through
:meth:novaos_client_sdk.client.Client._map_exception where it came from
below the protocol layer — so an interceptor sees a
:class:~novaos_client_sdk.exceptions.NovaOSAuthenticationError for a 401,
not a bare httpx.HTTPStatusError, matching what a caller catching the
same call directly would see.
card: AgentCard | None
instance-attribute
As :attr:BeforeArgs.card.
context: ClientCallContext
instance-attribute
As :attr:BeforeArgs.context.
error: Exception | None
instance-attribute
The exception the call raised, or None on success.
method: str
instance-attribute
As :attr:BeforeArgs.method.
params: dict[str, Any]
instance-attribute
As :attr:BeforeArgs.params.
result: Any
instance-attribute
The call's raw JSON-RPC result, or one decoded stream frame for a
streaming call. None when error is set.
BeforeArgs(method: str, params: dict[str, Any], http_kwargs: dict[str, Any], card: AgentCard | None, context: ClientCallContext)
dataclass
What :meth:ClientCallInterceptor.before sees, ahead of one call.
http_kwargs is mutated in place, not returned: several interceptors run
in sequence over the same call, and each one adding to a shared dict is how
a second interceptor's headers can sit alongside a first's without either
having to know the other ran. Today the only key the transport reads is
"headers" (see :meth:novaos_client_sdk._a2a.A2ATransport.call), kept
as a generic dict[str, Any] rather than a bare headers= field so a
later interceptor (a per-call timeout override, say) has somewhere to put
it without another signature change here.
card: AgentCard | None
instance-attribute
The agent's Agent Card, if the :class:~novaos_client_sdk.client.Client
was constructed with one — see
:attr:novaos_client_sdk.client.Client.card. None under exactly the
same conditions it is None there.
context: ClientCallContext
instance-attribute
This call's context — the caller's own if one was passed to the
Client method, else a fresh one created for this call alone.
http_kwargs: dict[str, Any]
instance-attribute
Extra keyword arguments to merge into the transport call. Mutate this in place to add to it.
method: str
instance-attribute
The A2A JSON-RPC method about to be called, e.g. "SendMessage".
params: dict[str, Any]
instance-attribute
The request's params object, already built by the Client
method — read-only in practice: mutating it here would change what the
agent sees without the interceptor's own record (after) reflecting
that, so an interceptor that needs to change the request is a bigger
change than this ticket, not a use of this field.
ClientCallContext(state: dict[str, Any] = dict(), timeout: float | None = None, service_parameters: dict[str, Any] | None = None)
dataclass
Per-call state threaded through every interceptor for one client call.
One instance covers one call to :class:~novaos_client_sdk.client.Client —
including, for a streaming call, every frame of it — so an interceptor can
stash something in :attr:state in before and read it back in
after. Passing None (the default on every Client method) is a
normal, fully supported call: a fresh :class:ClientCallContext is created
for it, exactly as empty as one the caller built themselves.
service_parameters: dict[str, Any] | None = None
class-attribute
instance-attribute
Free-form parameters a caller wants visible to every interceptor for
this call, distinct from :attr:state: this is set once by the caller
before the call starts, where state is for interceptors to read and
write among themselves.
state: dict[str, Any] = field(default_factory=dict)
class-attribute
instance-attribute
Mutable scratch space, private to this one call. An interceptor that
needs to correlate before and after — a request timestamp, a
session id looked up once — keeps it here rather than as instance state on
the interceptor itself, which would leak across concurrent calls on the
same :class:~novaos_client_sdk.client.Client.
timeout: float | None = None
class-attribute
instance-attribute
Per-call timeout override. Unread by the client today — carried here so an interceptor or a future ticket has somewhere to put one without another signature change.
ClientCallInterceptor
Cross-cutting behaviour that runs around every Client call.
Both methods default to a no-op, so an interceptor that only cares about
one side of a call — :class:~novaos_client_sdk.auth_interceptor.AuthInterceptor
implements before only — does not have to write an empty override for
the other. Neither method returns a value: before acts by mutating
args.http_kwargs, and after is a notification, not a filter — it
cannot change what the caller already received.
Example
after(args: AfterArgs) -> None
async
Run once one call — or one streamed frame — has answered. Override
to observe args.result / args.error — see :class:AfterArgs.
before(args: BeforeArgs) -> None
async
Run ahead of one call. Override to inspect or add to
args.http_kwargs — see :class:BeforeArgs.
Raising here aborts the call before any transport I/O has started: see
:meth:novaos_client_sdk.client.Client._execute.
novaos_client_sdk.config
Configuration helpers for the NovaOS Client SDK.
get_allowed_hosts_from_env() -> list[str] | None
Read the outbound-host allowlist from the NOVAOS_ALLOWED_HOSTS env var.
Returns:
| Type | Description |
|---|---|
list[str] | None
|
A list of hostname suffixes (e.g. |
list[str] | None
|
or |
get_default_tool_timeout_s() -> int
Get the default per-tool MCP call timeout in seconds.
Checks the NOVAOS_TOOL_TIMEOUT_S environment variable; defaults to 128s
(chosen to exceed the MCP Gateway's own upstream ceilings — 120s for
built-in/service-registry-routed tools, 30s by default for T1
manifest-registered tools unless the manifest overrides it — with an 8s
margin, so a caller sees the Gateway's own timeout error rather than a
premature SDK-side timeout). See docs/timeout-hierarchy.md for the
full breakdown; there is no single platform-wide "the gateway timeout" to
validate against, since it varies by which dispatch path a tool takes.
Returns:
| Type | Description |
|---|---|
int
|
Default timeout in seconds for MCP tool calls that don't specify |
int
|
their own |
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
get_dns_url_for_environment(environment: Environment | None = None) -> str
Raise, naming the environment variables that configure a DNS URL.
There is no hardcoded default DNS host for any environment — every
deployment must set one of the environment variables documented on
:func:get_dns_url_from_env. This function stays exported for callers
that want an environment-scoped lookup, but it always raises: a coarse
per-environment fallback host would otherwise mask a missing platform
configuration with a URL that resolves nowhere.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
environment
|
Environment | None
|
Environment name ("dev", "staging", or "prod").
If None, uses |
None
|
Raises:
| Type | Description |
|---|---|
ValueError
|
Always — no default exists. The message names
|
get_dns_url_from_env(custom_url: str | None = None, env_var: str | None = None, environment: Environment | None = None) -> str
Get DNS URL from environment variables or defaults.
Priority order:
1. Explicit custom_url parameter (highest priority)
2. Custom env_var parameter
3. AGENT_DNS_URL — matches NovaOS control-plane / Temporal worker convention
4. ACP_DNS_URL — backward-compatible generic variable
5. DNS_SERVICE_HOST + DNS_SERVICE_PORT — matches agent-registrar convention
6. Environment-specific variable (e.g., ACP_DNS_URL_DEV)
7. Raises if none of the above is set — there is no environment default
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
custom_url
|
str | None
|
Explicit URL to use (highest priority). If provided, all other options are ignored. |
None
|
env_var
|
str | None
|
Custom environment variable name to check (default: None, uses standard names). If provided, checks this variable before platform-standard variables. |
None
|
environment
|
Environment | None
|
Environment name to use for environment-specific variables and defaults.
If None, uses |
None
|
Returns:
| Type | Description |
|---|---|
str
|
DNS URL string. |
Raises:
| Type | Description |
|---|---|
ValueError
|
If a URL is provided but is invalid (empty or not a valid URL format), or if none of the DNS variables above are set — there is no hardcoded fallback host for any environment. |
Environment Variables (checked in order):
- AGENT_DNS_URL: Full URL used by NovaOS control-plane components
- ACP_DNS_URL: Generic DNS URL (backward-compatible)
- DNS_SERVICE_HOST: Agent DNS hostname (used with DNS_SERVICE_PORT)
- DNS_SERVICE_PORT: Agent DNS port (default: 8000, used with DNS_SERVICE_HOST)
- ACP_DNS_URL_DEV / ACP_DNS_URL_STAGING / ACP_DNS_URL_PROD: Per-environment URLs
Example
import os
from novaos_client_sdk.config import get_dns_url_from_env
# Platform convention (recommended)
os.environ["AGENT_DNS_URL"] = "http://agent-dns.example.internal:8000"
url = get_dns_url_from_env()
# Agent-registrar convention (host + port)
os.environ["DNS_SERVICE_HOST"] = "agent-dns.example.internal"
os.environ["DNS_SERVICE_PORT"] = "8000"
url = get_dns_url_from_env()
# Backward-compatible
os.environ["ACP_DNS_URL"] = "http://localhost:8021"
url = get_dns_url_from_env()
# Explicit override (highest priority)
url = get_dns_url_from_env(custom_url="http://custom:8000")
get_environment() -> Environment
Get the current environment from environment variables.
Checks ENVIRONMENT or ENV environment variables (case-insensitive).
Defaults to "dev" if not set.
Returns:
| Type | Description |
|---|---|
Environment
|
Environment name: "dev", "staging", or "prod". |
validate_host_allowed(url: str, allowed_hosts: list[str] | None = None) -> None
Reject url if its hostname isn't covered by an outbound-host allowlist.
A caller-supplied SDK config value (gateway_url, base_url, etc.) is
trusted configuration, not attacker-controlled data — this is a coarse
suffix check, not the DNS-resolution + private-IP-blocking check
:func:novaos_client_sdk.dns.validate_agent_url does for dynamically
discovered agent URLs (a different, higher-risk trust boundary).
No-ops when no allowlist is configured (neither allowed_hosts nor the
NOVAOS_ALLOWED_HOSTS env var) — this is an opt-in restriction, not a
default-deny, so existing deployments are unaffected until they opt in.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
url
|
str
|
The URL the SDK is about to call. |
required |
allowed_hosts
|
list[str] | None
|
Explicit hostname-suffix allowlist. When |
None
|
Raises:
| Type | Description |
|---|---|
ValueError
|
If an allowlist is configured and |
validate_tool_timeout(timeout_s: int, gateway_timeout_s: int | None) -> None
Reject a per-tool timeout that is shorter than a caller-declared gateway ceiling.
The SDK's timeout is meant to sit above the MCP Gateway's own ceiling —
see docs/timeout-hierarchy.md — so the Gateway times out first and the
caller sees its (often more informative) error instead of a generic
client-side timeout while the tool keeps running server-side. A
timeout_s shorter than gateway_timeout_s reintroduces exactly that
bug. There is no single platform-wide Gateway timeout the SDK can assume —
it ranges from 30s to 120s depending on which dispatch path a given tool
takes, and manifest-registered tools can override it per-tool — so
validation is opt-in: pass gateway_timeout_s (the actual ceiling for
the tools you call — when in doubt, the smallest one, 30s, is always
safe) to enforce it; leave it None (the default) to skip validation
entirely.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
timeout_s
|
int
|
The effective per-tool timeout about to be used. |
required |
gateway_timeout_s
|
int | None
|
The caller-declared Gateway ceiling to validate
against, or |
required |
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
Models
novaos_client_sdk.models
Data models for NovaOS Client SDK.
Run, RunYield and YieldType were removed during the ACP→A2A
migration. They were ACP's result and streaming
envelopes, and client.run_sync / client.run_stream were the only things
that produced them; once those methods went, nothing in the SDK could fill either
shape. Their A2A replacements are :class:novaos_client_sdk.a2a_task.Task —
which carries everything Run did, plus the id the task-addressed operations
need — and the StreamResponse frames in
:mod:novaos_client_sdk.a2a_events. Per the migration's no-backward-compat
decision they were deleted rather than kept as aliases.
Agent was removed during the same migration for the same reason. It was the shape
discovery.py parsed ACP's GET /agents listing into — a NovaOS convention
rather than a protocol type — and that call is gone. Its A2A replacement is
:class:novaos_client_sdk.a2a_agent_card.AgentCard, which is the real protocol
document and carries what an agent actually publishes about itself: interfaces,
skills, declared extensions, security schemes. Capability stays, since
:class:novaos_client_sdk.dns.AgentDNSRecord and
:mod:novaos_client_sdk.capabilities still produce and match it.
:class:Message and :class:MessagePart stay, and are not ACP leftovers by
the same argument: they are a small convenience shape for constructing input,
:meth:novaos_client_sdk.client.Client.send_message converts one to a real A2A
message, and message_parser / message_validator still accept them. An
agent's output is never one of these.
Author(name: str, role: str | None = None, metadata: dict[str, Any] | None = None)
dataclass
Author model for message attribution.
from_dict(data: dict[str, Any]) -> Author
classmethod
Create from dictionary.
to_dict() -> dict[str, Any]
Convert to dictionary.
Capability(name: str, description: str | None = None, metadata: dict[str, Any] | None = None)
dataclass
Agent capability model.
from_dict(data: dict[str, Any]) -> Capability
classmethod
Create from dictionary.
to_dict() -> dict[str, Any]
Convert to dictionary.
Message(parts: list[MessagePart] = list(), author: Author | None = None, metadata: dict[str, Any] | None = None)
dataclass
Simple multi-part input message, for callers not building A2A types.
Accepted by :meth:novaos_client_sdk.client.Client.send_message alongside an
:class:~novaos_client_sdk.a2a_types.A2AMessage and a plain string.
from_dict(data: dict[str, Any]) -> Message
classmethod
Create Message from dictionary.
to_dict() -> dict[str, Any]
Convert message to dictionary representation.
validate() -> bool
Validate message structure and content.
Checks that: - Message has at least one part - All parts have non-empty content
Returns:
| Type | Description |
|---|---|
bool
|
True if message is valid, False otherwise |
MessagePart(content: str, content_type: str | ContentType | None = None, metadata: dict[str, Any] | None = None)
dataclass
Simple content-plus-content-type input part, with validation.
Converted to a real :class:~novaos_client_sdk.a2a_types.TextPart — with
content_type becoming A2A v1.0's own mediaType field — when it is
sent.
from_dict(data: dict[str, Any]) -> MessagePart
classmethod
Create from dictionary.
to_dict() -> dict[str, Any]
Convert to dictionary.
novaos_client_sdk.capabilities
Capability matching and indexing utilities for NovaOS Client SDK.
CapabilityIndex(agents: list[AgentDNSRecord])
Index for fast capability-based agent lookup.
Build index from agents.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
agents
|
list[AgentDNSRecord]
|
List of agents to index |
required |
find_agents_by_capabilities(capabilities: list[str], require_all: bool = True, fuzzy: bool = False) -> list[AgentDNSRecord]
Find agents matching multiple capabilities.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
capabilities
|
list[str]
|
List of required capability names |
required |
require_all
|
bool
|
If True, agent must have all capabilities (AND). If False, agent needs at least one (OR) |
True
|
fuzzy
|
bool
|
If True, use fuzzy matching |
False
|
Returns:
| Type | Description |
|---|---|
list[AgentDNSRecord]
|
List of agents matching the criteria |
find_agents_by_capability(capability: str, fuzzy: bool = False) -> list[AgentDNSRecord]
Find agents with specific capability.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
capability
|
str
|
Capability name to search for |
required |
fuzzy
|
bool
|
If True, use fuzzy matching |
False
|
Returns:
| Type | Description |
|---|---|
list[AgentDNSRecord]
|
List of agents with the capability |
CapabilityMatcher(similarity_threshold: float = 0.8, normalize_names: bool = True)
Service for matching capabilities to agents.
Initialize matcher.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
similarity_threshold
|
float
|
Minimum similarity score (0.0-1.0) for fuzzy matching |
0.8
|
normalize_names
|
bool
|
If True, normalize capability names before matching |
True
|
match(agents: list[AgentDNSRecord], required_capabilities: list[str], match_mode: str = 'any') -> list[MatchResult]
Match agents to required capabilities.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
agents
|
list[AgentDNSRecord]
|
List of agents to match against |
required |
required_capabilities
|
list[str]
|
List of required capability names |
required |
match_mode
|
str
|
"any" for OR logic (agent needs at least one), "all" for AND logic (agent must have all) |
'any'
|
Returns:
| Type | Description |
|---|---|
list[MatchResult]
|
List of MatchResult objects sorted by score (descending) |
MatchResult(agent: AgentDNSRecord, matched_capabilities: list[str], missing_capabilities: list[str], score: float)
dataclass
Result of capability matching.
Standardized messages
novaos_client_sdk.messages
Standardized message models for NovaOS Client SDK.
This module provides type-safe message models for standardized communication
between agents and clients, including HITL messages, error messages, and progress messages.
All messages are designed to work within A2A Message structures (spec v1.0.1);
they are carried as a DataPart, built by message_builder.
Helper classes and utilities are in separate modules: - message_builder.py: MessageBuilder class - message_parser.py: MessageParser class - message_validator.py: MessageValidator class - message_helpers.py: Convenience functions
ErrorMessage(error_type: ErrorType | str, message: str, session_id: str | None = None, details: ErrorDetails = dict(), recovery_suggestions: list[str] = list(), retryable: bool = False)
dataclass
Standardized error message model.
connection_error(message: str, session_id: str | None = None, details: ErrorDetails | None = None, recovery_suggestions: list[str] | None = None, retryable: bool = True) -> ErrorMessage
classmethod
Factory method for connection error.
from_dict(data: dict[str, Any]) -> ErrorMessage
classmethod
Create ErrorMessage from dictionary.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
dict[str, Any]
|
Dictionary containing error message data |
required |
Returns:
| Type | Description |
|---|---|
ErrorMessage
|
ErrorMessage instance |
Raises:
| Type | Description |
|---|---|
KeyError
|
If required fields are missing |
not_found_error(message: str, session_id: str | None = None, details: ErrorDetails | None = None, recovery_suggestions: list[str] | None = None, retryable: bool = False) -> ErrorMessage
classmethod
Factory method for not found error.
to_dict() -> dict[str, Any]
Convert error message to dictionary.
validation_error(message: str, session_id: str | None = None, details: ErrorDetails | None = None, recovery_suggestions: list[str] | None = None, retryable: bool = False) -> ErrorMessage
classmethod
Factory method for validation error.
ErrorType
Bases: StrEnum
Error type enumeration.
HITLMessage(hitl_type: HITLMessageType | str, content: str, requires_user_action: bool, session_id: str | None = None, metadata: dict[str, Any] = dict(), actions: list[Action] = list())
dataclass
Human-in-the-Loop message model.
Used when an agent needs user input, confirmation, or decision-making.
batch_input_request(content: str, fields: list[InputField], session_id: str | None = None, metadata: dict[str, Any] | None = None, actions: list[Action] | None = None) -> HITLMessage
classmethod
Request multiple inputs from the user in a single form.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
content
|
str
|
Introductory prompt text for the form. |
required |
fields
|
list[InputField]
|
List of |
required |
session_id
|
str | None
|
Session identifier for the HITL conversation. |
None
|
metadata
|
dict[str, Any] | None
|
Additional metadata to attach. |
None
|
actions
|
list[Action] | None
|
Action buttons (defaults to a Submit button). |
None
|
error_notification(content: str, error_type: str, recovery_suggestions: list[str] | None = None, session_id: str | None = None, requires_user_action: bool = False, actions: list[Action] | None = None, metadata: dict[str, Any] | None = None) -> HITLMessage
classmethod
Factory method for error notification HITL message.
execution_progress(content: str, progress: ProgressData, session_id: str | None = None, metadata: dict[str, Any] | None = None) -> HITLMessage
classmethod
Factory method for execution progress HITL message.
from_dict(data: dict[str, Any]) -> HITLMessage
classmethod
Create HITLMessage from dictionary.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
dict[str, Any]
|
Dictionary containing HITL message data |
required |
Returns:
| Type | Description |
|---|---|
HITLMessage
|
HITLMessage instance |
Raises:
| Type | Description |
|---|---|
KeyError
|
If required fields are missing |
ValueError
|
If data is invalid |
info_message(content: str, session_id: str | None = None, metadata: dict[str, Any] | None = None) -> HITLMessage
classmethod
Factory method for informational HITL message.
input_request(content: str, input_type: InputType | str = InputType.TEXT, validation: InputValidation | None = None, session_id: str | None = None, metadata: dict[str, Any] | None = None, actions: list[Action] | None = None) -> HITLMessage
classmethod
Factory method for input request HITL message.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
content
|
str
|
Prompt text for the user. |
required |
input_type
|
InputType | str
|
Type of input expected (e.g., InputType.EMAIL). Accepts
both |
TEXT
|
validation
|
InputValidation | None
|
Optional validation rules for client-side validation. |
None
|
session_id
|
str | None
|
Session identifier for the HITL conversation. |
None
|
metadata
|
dict[str, Any] | None
|
Additional metadata to attach. |
None
|
actions
|
list[Action] | None
|
Action buttons (typically empty for input requests). |
None
|
Note: Input requests typically don't require action buttons as the user provides input directly. If actions are provided, they will be used.
plan_confirmation(content: str, plan: dict[str, Any], actions: list[Action], session_id: str | None = None, metadata: dict[str, Any] | None = None) -> HITLMessage
classmethod
Factory method for plan confirmation HITL message.
success_message(content: str, session_id: str | None = None, result: dict[str, Any] | None = None, metadata: dict[str, Any] | None = None) -> HITLMessage
classmethod
Factory method for success HITL message.
to_dict() -> dict[str, Any]
Convert HITL message to dictionary.
HITLMessageType
Bases: StrEnum
HITL message type enumeration.
HITLResponse(action_id: str, session_id: str, input_value: str | None = None, metadata: dict[str, Any] = dict())
dataclass
Human-in-the-Loop response model.
Used when users respond to HITL messages.
from_dict(data: dict[str, Any]) -> HITLResponse
classmethod
Create HITLResponse from dictionary.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
dict[str, Any]
|
Dictionary containing HITL response data |
required |
Returns:
| Type | Description |
|---|---|
HITLResponse
|
HITLResponse instance |
Raises:
| Type | Description |
|---|---|
KeyError
|
If required fields are missing |
to_dict() -> dict[str, Any]
Convert HITL response to dictionary.
InputField(name: str, label: str, input_type: InputType = InputType.TEXT, validation: InputValidation | None = None, description: str | None = None)
dataclass
A single field in a batch input request.
Used by HITLMessage.batch_input_request() to describe each field
in a multi-field form.
InputType
Bases: StrEnum
Supported input types for HITL input_request messages.
Agents use these to declare what kind of value they need. The frontend uses them to render the appropriate input widget and apply client-side validation.
InputValidation(required: bool = True, min_length: int | None = None, max_length: int | None = None, min_value: float | None = None, max_value: float | None = None, pattern: str | None = None, pattern_message: str | None = None, placeholder: str | None = None, default_value: str | None = None, options: list[dict[str, str]] | None = None)
dataclass
Validation rules for HITL input_request messages.
Agents attach these to input requests so the frontend can validate user input before submitting (faster feedback loop). The agent should still validate server-side as a safety net.
MessageBuilder
Utility class for building and embedding standardized messages in A2A Messages.
create_error_message(content: str, error_type: ErrorType | str, details: ErrorDetails | None = None, recovery_suggestions: list[str] | None = None, retryable: bool = False, session_id: str | None = None) -> A2AMessage
staticmethod
Create an A2A Message with error message embedded as a metadata part.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
content
|
str
|
Human-readable error message |
required |
error_type
|
ErrorType | str
|
Type of error (ErrorType enum or string) |
required |
details
|
ErrorDetails | None
|
Optional error details |
None
|
recovery_suggestions
|
list[str] | None
|
Optional recovery suggestions |
None
|
retryable
|
bool
|
Whether the error is retryable |
False
|
session_id
|
str | None
|
Optional session ID |
None
|
Returns:
| Type | Description |
|---|---|
A2AMessage
|
A2AMessage with error embedded as a DataPart |
create_hitl_message(content: str, hitl_type: HITLMessageType | str, plan: dict[str, Any] | None = None, actions: list[Action] | None = None, session_id: str | None = None, input_type: InputType | str | None = None, validation: InputValidation | dict[str, Any] | None = None, metadata: dict[str, Any] | None = None) -> A2AMessage
staticmethod
Create an A2A Message with HITL message embedded as a metadata part.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
content
|
str
|
Human-readable message content |
required |
hitl_type
|
HITLMessageType | str
|
Type of HITL message (HITLMessageType enum or string) |
required |
plan
|
dict[str, Any] | None
|
Optional plan data (for plan_confirmation) |
None
|
actions
|
list[Action] | None
|
Optional list of action buttons |
None
|
session_id
|
str | None
|
Optional session ID for multi-turn conversations |
None
|
input_type
|
InputType | str | None
|
Optional input type (for input_request). Accepts
|
None
|
validation
|
InputValidation | dict[str, Any] | None
|
Optional validation rules (for input_request). Accepts
|
None
|
metadata
|
dict[str, Any] | None
|
Optional additional metadata |
None
|
Returns:
| Type | Description |
|---|---|
A2AMessage
|
A2AMessage with HITL embedded as a DataPart |
create_hitl_response(action_id: str, session_id: str, input_value: str | None = None, metadata: dict[str, Any] | None = None) -> HITLResponse
staticmethod
Create a HITL response object.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
action_id
|
str
|
Action ID from user's response |
required |
session_id
|
str
|
Session ID for multi-turn conversations |
required |
input_value
|
str | None
|
Optional input value (for input_request responses) |
None
|
metadata
|
dict[str, Any] | None
|
Optional additional metadata |
None
|
Returns:
| Type | Description |
|---|---|
HITLResponse
|
HITLResponse object |
create_message_with_hitl(content: str, hitl: HITLMessage, content_type: str = _DEFAULT_MEDIA_TYPE) -> A2AMessage
staticmethod
Create an A2A Message with HITL message embedded as a metadata part.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
content
|
str
|
Human-readable content for the visible TextPart |
required |
hitl
|
HITLMessage
|
HITL message to embed |
required |
content_type
|
str
|
Media type for the visible TextPart. A2A's TextPart has
no content-type field, so anything other than |
_DEFAULT_MEDIA_TYPE
|
Returns:
| Type | Description |
|---|---|
A2AMessage
|
A2AMessage with HITL embedded as a DataPart |
create_message_with_hitl_response(content: str, action_id: str, session_id: str, input_value: str | None = None, metadata: dict[str, Any] | None = None) -> A2AMessage
staticmethod
Create an A2A Message with HITL response embedded as a metadata part.
A HITL response travels from the user back to the agent, so the message
carries role="user" — unlike the agent-authored factory methods above.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
content
|
str
|
Human-readable content for the visible TextPart |
required |
action_id
|
str
|
Action ID from user's response |
required |
session_id
|
str
|
Session ID for multi-turn conversations |
required |
input_value
|
str | None
|
Optional input value (for input_request responses) |
None
|
metadata
|
dict[str, Any] | None
|
Optional additional metadata |
None
|
Returns:
| Type | Description |
|---|---|
A2AMessage
|
A2AMessage with HITL response embedded as a DataPart |
create_oauth_required_message(content: str, connector: str, auth_url: str, session_id: str | None = None, metadata: dict[str, Any] | None = None) -> A2AMessage
staticmethod
Create an A2A Message pausing the run for a connector OAuth flow.
Under ACP this pause was its own event type, connector.oauth_required;
A2A has no event vocabulary, so it travels as a NovaOS metadata part under
:data:NOVAOS_PAUSE_OAUTH_KEY, read on the platform side by
extractPausePayloads in the platform's A2A wire contract. A caller
that also wants the task itself to reach TASK_STATE_AUTH_REQUIRED
(rather than relying on this payload's mere presence) sets that state on
the Task it builds around this message — this factory only builds the
message, matching every other create_* method here.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
content
|
str
|
Human-readable message content (e.g. "Connect your Gmail account to continue.") |
required |
connector
|
str
|
The connector needing authorization (e.g. "gmail"). |
required |
auth_url
|
str
|
The URL the user must visit to complete the OAuth flow. |
required |
session_id
|
str | None
|
Optional session ID for multi-turn conversations. |
None
|
metadata
|
dict[str, Any] | None
|
Optional additional fields merged into the |
None
|
Returns:
| Type | Description |
|---|---|
A2AMessage
|
A2AMessage with the OAuth pause embedded as a DataPart |
create_progress_message(content: str, status: ProgressStatus | str, progress: ProgressData, metadata: dict[str, Any] | None = None, session_id: str | None = None) -> A2AMessage
staticmethod
Create an A2A Message with progress message embedded as a metadata part.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
content
|
str
|
Human-readable progress message |
required |
status
|
ProgressStatus | str
|
Progress status (ProgressStatus enum or string) |
required |
progress
|
ProgressData
|
Progress data dictionary |
required |
metadata
|
dict[str, Any] | None
|
Optional additional metadata |
None
|
session_id
|
str | None
|
Optional session ID |
None
|
Returns:
| Type | Description |
|---|---|
A2AMessage
|
A2AMessage with progress embedded as a DataPart |
create_write_confirmation_required_message(content: str, call_id: str, tool_name: str, session_id: str | None = None, metadata: dict[str, Any] | None = None) -> A2AMessage
staticmethod
Create an A2A Message pausing the run for a destructive-tool confirmation.
Under ACP this pause was its own event type, tool.write_confirmation_required;
A2A has no event vocabulary, so it travels as a NovaOS metadata part under
:data:NOVAOS_PAUSE_WRITE_CONFIRMATION_KEY, read on the platform side by
extractPausePayloads. The Temporal worker mints its resume execution
token bound to call_id in its background execution path, so an agent
that omits it turns a confirmable write into a failed run rather than a
blanket write authorization — always pass the real call_id the tool
call carried, never a placeholder.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
content
|
str
|
Human-readable message content (e.g. "Confirm sending this email?") |
required |
call_id
|
str
|
The id of the pending tool call the confirmation is bound to. |
required |
tool_name
|
str
|
The name of the tool awaiting confirmation. |
required |
session_id
|
str | None
|
Optional session ID for multi-turn conversations. |
None
|
metadata
|
dict[str, Any] | None
|
Optional additional fields merged into the
|
None
|
Returns:
| Type | Description |
|---|---|
A2AMessage
|
A2AMessage with the write-confirmation pause embedded as a DataPart |
embed_error_message(message: A2AMessage, error: ErrorMessage) -> A2AMessage
staticmethod
Embed an error message in an existing A2A Message as a metadata part.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
message
|
A2AMessage
|
Existing A2A Message |
required |
error
|
ErrorMessage
|
Error message to embed |
required |
Returns:
| Type | Description |
|---|---|
A2AMessage
|
New A2AMessage with error appended as a DataPart |
embed_hitl_message(message: A2AMessage, hitl: HITLMessage) -> A2AMessage
staticmethod
Embed a HITL message in an existing A2A Message as a metadata part.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
message
|
A2AMessage
|
Existing A2A Message |
required |
hitl
|
HITLMessage
|
HITL message to embed |
required |
Returns:
| Type | Description |
|---|---|
A2AMessage
|
New A2AMessage with HITL appended as a DataPart |
embed_hitl_response(message: A2AMessage, hitl_response: HITLResponse) -> A2AMessage
staticmethod
Embed a HITL response in an existing A2A Message as a metadata part.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
message
|
A2AMessage
|
Existing A2A Message |
required |
hitl_response
|
HITLResponse
|
HITL response to embed |
required |
Returns:
| Type | Description |
|---|---|
A2AMessage
|
New A2AMessage with HITL response appended as a DataPart |
embed_progress_message(message: A2AMessage, progress: ProgressMessage) -> A2AMessage
staticmethod
Embed a progress message in an existing A2A Message as a metadata part.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
message
|
A2AMessage
|
Existing A2A Message |
required |
progress
|
ProgressMessage
|
Progress message to embed |
required |
Returns:
| Type | Description |
|---|---|
A2AMessage
|
New A2AMessage with progress appended as a DataPart |
MessageParser
Utility class for parsing and extracting standardized messages from A2A Messages.
extract_all(message: Any) -> dict[str, Any]
staticmethod
Extract all standardized messages from a message.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
message
|
Any
|
A2A, ACP-shaped, or legacy message to parse |
required |
Returns:
| Type | Description |
|---|---|
dict[str, Any]
|
Dictionary with keys 'hitl', 'error', 'progress', 'hitl_response' |
dict[str, Any]
|
(only present if found) |
extract_error_message(message: Any) -> ErrorMessage | None
staticmethod
Extract error message from message metadata.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
message
|
Any
|
A2A, ACP-shaped, or legacy message to parse |
required |
Returns:
| Type | Description |
|---|---|
ErrorMessage | None
|
ErrorMessage if found, None otherwise |
extract_hitl_message(message: Any) -> HITLMessage | None
staticmethod
Extract HITL message from message metadata.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
message
|
Any
|
A2A, ACP-shaped, or legacy message to parse |
required |
Returns:
| Type | Description |
|---|---|
HITLMessage | None
|
HITLMessage if found, None otherwise |
extract_hitl_response(message: Any) -> HITLResponse | None
staticmethod
Extract HITL response from message metadata.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
message
|
Any
|
A2A, ACP-shaped, or legacy message to parse |
required |
Returns:
| Type | Description |
|---|---|
HITLResponse | None
|
HITLResponse if found, None otherwise |
extract_progress_message(message: Any) -> ProgressMessage | None
staticmethod
Extract progress message from message metadata.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
message
|
Any
|
A2A, ACP-shaped, or legacy message to parse |
required |
Returns:
| Type | Description |
|---|---|
ProgressMessage | None
|
ProgressMessage if found, None otherwise |
is_error_message(message: Any) -> bool
staticmethod
Check if message contains an error message.
is_hitl_message(message: Any) -> bool
staticmethod
Check if message contains a HITL message.
is_hitl_response(message: Any) -> bool
staticmethod
Check if message contains a HITL response.
is_progress_message(message: Any) -> bool
staticmethod
Check if message contains a progress message.
MessageValidator
Utility class for validating standardized message structures.
get_validation_errors(data: dict[str, Any], message_type: str | None = None) -> list[str]
staticmethod
Get validation errors for a message dictionary.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
dict[str, Any]
|
Dictionary to validate |
required |
message_type
|
str | None
|
Optional message type hint ('hitl', 'error', 'progress', 'hitl_response') |
None
|
Returns:
| Type | Description |
|---|---|
list[str]
|
List of validation error messages |
validate_a2a_envelope(message: Any) -> bool
staticmethod
Validate the A2A-specific envelope fields of a message.
A2A spec v1.0.1 requires messageId, role and at least one part
on every Message. ACP had no such envelope, so this check only
applies to messages that present as A2A; anything else passes here,
leaving format-agnostic validation to :meth:validate_message.
(v0.3 also required kind == "message". v1.0 dropped that field —
see F18 — so the A2A-shape test is structural now, not a discriminator
lookup.)
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
message
|
Any
|
Message to validate |
required |
Returns:
| Type | Description |
|---|---|
bool
|
True if the message is not A2A-shaped, or is a valid A2A envelope |
validate_error_message(data: dict[str, Any]) -> bool
staticmethod
Validate error message structure.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
dict[str, Any]
|
Dictionary to validate |
required |
Returns:
| Type | Description |
|---|---|
bool
|
True if valid, False otherwise |
validate_hitl_message(data: dict[str, Any]) -> bool
staticmethod
Validate HITL message structure.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
dict[str, Any]
|
Dictionary to validate |
required |
Returns:
| Type | Description |
|---|---|
bool
|
True if valid, False otherwise |
validate_hitl_response(data: dict[str, Any]) -> bool
staticmethod
Validate HITL response structure.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
dict[str, Any]
|
Dictionary to validate |
required |
Returns:
| Type | Description |
|---|---|
bool
|
True if valid, False otherwise |
validate_message(message: Any) -> bool
staticmethod
Validate a message and any embedded standardized messages.
Supports A2A messages (novaos_client_sdk.a2a_types.A2AMessage),
ACP-shaped messages, and novaos_client_sdk.models.Message.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
message
|
Any
|
A2A, ACP-shaped, or legacy message to validate |
required |
Returns:
| Type | Description |
|---|---|
bool
|
True if valid, False otherwise |
validate_progress_message(data: dict[str, Any]) -> bool
staticmethod
Validate progress message structure.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
dict[str, Any]
|
Dictionary to validate |
required |
Returns:
| Type | Description |
|---|---|
bool
|
True if valid, False otherwise |
ProgressMessage(status: ProgressStatus | str, message: str, session_id: str | None = None, progress: ProgressData = dict(), metadata: dict[str, Any] = dict())
dataclass
Standardized progress message model.
from_dict(data: dict[str, Any]) -> ProgressMessage
classmethod
Create ProgressMessage from dictionary.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
dict[str, Any]
|
Dictionary containing progress message data |
required |
Returns:
| Type | Description |
|---|---|
ProgressMessage
|
ProgressMessage instance |
Raises:
| Type | Description |
|---|---|
KeyError
|
If required fields are missing |
to_dict() -> dict[str, Any]
Convert progress message to dictionary.
ProgressStatus
Bases: StrEnum
Progress status enumeration.
create_message_with_hitl(content: str, hitl: HITLMessage) -> Message
Create a message with HITL message embedded.
Convenience function that wraps MessageBuilder.create_message_with_hitl().
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
content
|
str
|
Human-readable content for MessagePart |
required |
hitl
|
HITLMessage
|
HITL message to embed |
required |
Returns:
| Type | Description |
|---|---|
Message
|
Message with HITL embedded in metadata |
create_message_with_hitl_response(content: str, action_id: str, session_id: str, input_value: str | None = None) -> Message
Create a message with HITL response embedded.
Convenience function that wraps MessageBuilder.create_message_with_hitl_response().
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
content
|
str
|
Human-readable content for MessagePart |
required |
action_id
|
str
|
Action ID from user's response |
required |
session_id
|
str
|
Session ID for multi-turn conversations |
required |
input_value
|
str | None
|
Optional input value (for input_request responses) |
None
|
Returns:
| Type | Description |
|---|---|
Message
|
Message with HITL response embedded in metadata |
embed_error_message(message: Message, error: ErrorMessage) -> Message
Embed an error message in an existing message's metadata.
Convenience function that wraps MessageBuilder.embed_error_message().
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
message
|
Message
|
Existing message |
required |
error
|
ErrorMessage
|
Error message to embed |
required |
Returns:
| Type | Description |
|---|---|
Message
|
New Message with error embedded in metadata |
embed_hitl_message(message: Message, hitl: HITLMessage) -> Message
Embed a HITL message in an existing message's metadata.
Convenience function that wraps MessageBuilder.embed_hitl_message().
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
message
|
Message
|
Existing message |
required |
hitl
|
HITLMessage
|
HITL message to embed |
required |
Returns:
| Type | Description |
|---|---|
Message
|
New Message with HITL embedded in metadata |
embed_hitl_response(message: Message, hitl_response: HITLResponse) -> Message
Embed a HITL response in an existing message's metadata.
Convenience function that wraps MessageBuilder.embed_hitl_response().
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
message
|
Message
|
Existing message |
required |
hitl_response
|
HITLResponse
|
HITL response to embed |
required |
Returns:
| Type | Description |
|---|---|
Message
|
New Message with HITL response embedded in metadata |
embed_progress_message(message: Message, progress: ProgressMessage) -> Message
Embed a progress message in an existing message's metadata.
Convenience function that wraps MessageBuilder.embed_progress_message().
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
message
|
Message
|
Existing message |
required |
progress
|
ProgressMessage
|
Progress message to embed |
required |
Returns:
| Type | Description |
|---|---|
Message
|
New Message with progress embedded in metadata |
extract_error_message(message: Message) -> ErrorMessage | None
Extract error message from message metadata.
Convenience function that wraps MessageParser.extract_error_message().
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
message
|
Message
|
message to parse |
required |
Returns:
| Type | Description |
|---|---|
ErrorMessage | None
|
ErrorMessage if found, None otherwise |
extract_hitl_message(message: Message) -> HITLMessage | None
Extract HITL message from message metadata.
Convenience function that wraps MessageParser.extract_hitl_message().
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
message
|
Message
|
message to parse |
required |
Returns:
| Type | Description |
|---|---|
HITLMessage | None
|
HITLMessage if found, None otherwise |
extract_hitl_response(message: Message) -> HITLResponse | None
Extract HITL response from message metadata.
Convenience function that wraps MessageParser.extract_hitl_response().
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
message
|
Message
|
message to parse |
required |
Returns:
| Type | Description |
|---|---|
HITLResponse | None
|
HITLResponse if found, None otherwise |
extract_progress_message(message: Message) -> ProgressMessage | None
Extract progress message from message metadata.
Convenience function that wraps MessageParser.extract_progress_message().
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
message
|
Message
|
message to parse |
required |
Returns:
| Type | Description |
|---|---|
ProgressMessage | None
|
ProgressMessage if found, None otherwise |
has_novaos_metadata_part(message: Message) -> bool
Check whether a message carries a NovaOS metadata part.
Recognizes both the A2A DataPart form and the ACP vendor-content-type
form, mirroring the parser's own dual-format handling.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
message
|
Message
|
Message to check |
required |
Returns:
| Type | Description |
|---|---|
bool
|
True if a NovaOS metadata part is present, False otherwise |
is_a2a_message(message: Message) -> bool
Check whether a message is A2A-shaped (spec v1.0.1).
Useful for call sites that still receive a mix of formats and need to branch
— e.g. reading context_id (A2A) versus a session_id buried in the
NovaOS metadata payload (ACP).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
message
|
Message
|
Message to check |
required |
Returns:
| Type | Description |
|---|---|
bool
|
True if the message presents as an A2A Message, False otherwise |
is_error_message(message: Message) -> bool
Check if message contains an error message.
Convenience function that wraps MessageParser.is_error_message().
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
message
|
Message
|
message to check |
required |
Returns:
| Type | Description |
|---|---|
bool
|
True if message contains error message, False otherwise |
is_hitl_message(message: Message) -> bool
Check if message contains a HITL message.
Convenience function that wraps MessageParser.is_hitl_message().
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
message
|
Message
|
message to check |
required |
Returns:
| Type | Description |
|---|---|
bool
|
True if message contains HITL message, False otherwise |
is_hitl_response(message: Message) -> bool
Check if message contains a HITL response.
Convenience function that wraps MessageParser.is_hitl_response().
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
message
|
Message
|
message to check |
required |
Returns:
| Type | Description |
|---|---|
bool
|
True if message contains HITL response, False otherwise |
is_progress_message(message: Message) -> bool
Check if message contains a progress message.
Convenience function that wraps MessageParser.is_progress_message().
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
message
|
Message
|
message to check |
required |
Returns:
| Type | Description |
|---|---|
bool
|
True if message contains progress message, False otherwise |
novaos_client_sdk.message_builder
MessageBuilder utility for creating and embedding standardized messages in A2A Messages.
IMPORTANT: All factory methods (create_, embed_) return
novaos_client_sdk.a2a_types.A2AMessage — NovaOS's own A2A-native message
type, targeting A2A specification v1.0.1 (see a2a_types.A2A_SPEC_VERSION).
They previously returned
acp_sdk.models.Message; ACP is dead (the upstream repo has been archived
since 2025-08-25) and there is no backward-compatibility requirement, so this is
a clean cutover rather than a dual-format builder.
Structured metadata (HITL, errors, progress) is carried as a second part. Under
ACP that was a MessagePart holding a JSON string with the vendor
content-type application/vnd.novaos.metadata+json; under A2A it is a
first-class DataPart whose data is the payload object, tagged via the
part's own metadata with NOVAOS_METADATA_PART_KEY so consumers can tell
NovaOS control metadata apart from an agent's ordinary structured output.
MessageBuilder
Utility class for building and embedding standardized messages in A2A Messages.
create_error_message(content: str, error_type: ErrorType | str, details: ErrorDetails | None = None, recovery_suggestions: list[str] | None = None, retryable: bool = False, session_id: str | None = None) -> A2AMessage
staticmethod
Create an A2A Message with error message embedded as a metadata part.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
content
|
str
|
Human-readable error message |
required |
error_type
|
ErrorType | str
|
Type of error (ErrorType enum or string) |
required |
details
|
ErrorDetails | None
|
Optional error details |
None
|
recovery_suggestions
|
list[str] | None
|
Optional recovery suggestions |
None
|
retryable
|
bool
|
Whether the error is retryable |
False
|
session_id
|
str | None
|
Optional session ID |
None
|
Returns:
| Type | Description |
|---|---|
A2AMessage
|
A2AMessage with error embedded as a DataPart |
create_hitl_message(content: str, hitl_type: HITLMessageType | str, plan: dict[str, Any] | None = None, actions: list[Action] | None = None, session_id: str | None = None, input_type: InputType | str | None = None, validation: InputValidation | dict[str, Any] | None = None, metadata: dict[str, Any] | None = None) -> A2AMessage
staticmethod
Create an A2A Message with HITL message embedded as a metadata part.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
content
|
str
|
Human-readable message content |
required |
hitl_type
|
HITLMessageType | str
|
Type of HITL message (HITLMessageType enum or string) |
required |
plan
|
dict[str, Any] | None
|
Optional plan data (for plan_confirmation) |
None
|
actions
|
list[Action] | None
|
Optional list of action buttons |
None
|
session_id
|
str | None
|
Optional session ID for multi-turn conversations |
None
|
input_type
|
InputType | str | None
|
Optional input type (for input_request). Accepts
|
None
|
validation
|
InputValidation | dict[str, Any] | None
|
Optional validation rules (for input_request). Accepts
|
None
|
metadata
|
dict[str, Any] | None
|
Optional additional metadata |
None
|
Returns:
| Type | Description |
|---|---|
A2AMessage
|
A2AMessage with HITL embedded as a DataPart |
create_hitl_response(action_id: str, session_id: str, input_value: str | None = None, metadata: dict[str, Any] | None = None) -> HITLResponse
staticmethod
Create a HITL response object.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
action_id
|
str
|
Action ID from user's response |
required |
session_id
|
str
|
Session ID for multi-turn conversations |
required |
input_value
|
str | None
|
Optional input value (for input_request responses) |
None
|
metadata
|
dict[str, Any] | None
|
Optional additional metadata |
None
|
Returns:
| Type | Description |
|---|---|
HITLResponse
|
HITLResponse object |
create_message_with_hitl(content: str, hitl: HITLMessage, content_type: str = _DEFAULT_MEDIA_TYPE) -> A2AMessage
staticmethod
Create an A2A Message with HITL message embedded as a metadata part.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
content
|
str
|
Human-readable content for the visible TextPart |
required |
hitl
|
HITLMessage
|
HITL message to embed |
required |
content_type
|
str
|
Media type for the visible TextPart. A2A's TextPart has
no content-type field, so anything other than |
_DEFAULT_MEDIA_TYPE
|
Returns:
| Type | Description |
|---|---|
A2AMessage
|
A2AMessage with HITL embedded as a DataPart |
create_message_with_hitl_response(content: str, action_id: str, session_id: str, input_value: str | None = None, metadata: dict[str, Any] | None = None) -> A2AMessage
staticmethod
Create an A2A Message with HITL response embedded as a metadata part.
A HITL response travels from the user back to the agent, so the message
carries role="user" — unlike the agent-authored factory methods above.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
content
|
str
|
Human-readable content for the visible TextPart |
required |
action_id
|
str
|
Action ID from user's response |
required |
session_id
|
str
|
Session ID for multi-turn conversations |
required |
input_value
|
str | None
|
Optional input value (for input_request responses) |
None
|
metadata
|
dict[str, Any] | None
|
Optional additional metadata |
None
|
Returns:
| Type | Description |
|---|---|
A2AMessage
|
A2AMessage with HITL response embedded as a DataPart |
create_oauth_required_message(content: str, connector: str, auth_url: str, session_id: str | None = None, metadata: dict[str, Any] | None = None) -> A2AMessage
staticmethod
Create an A2A Message pausing the run for a connector OAuth flow.
Under ACP this pause was its own event type, connector.oauth_required;
A2A has no event vocabulary, so it travels as a NovaOS metadata part under
:data:NOVAOS_PAUSE_OAUTH_KEY, read on the platform side by
extractPausePayloads in the platform's A2A wire contract. A caller
that also wants the task itself to reach TASK_STATE_AUTH_REQUIRED
(rather than relying on this payload's mere presence) sets that state on
the Task it builds around this message — this factory only builds the
message, matching every other create_* method here.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
content
|
str
|
Human-readable message content (e.g. "Connect your Gmail account to continue.") |
required |
connector
|
str
|
The connector needing authorization (e.g. "gmail"). |
required |
auth_url
|
str
|
The URL the user must visit to complete the OAuth flow. |
required |
session_id
|
str | None
|
Optional session ID for multi-turn conversations. |
None
|
metadata
|
dict[str, Any] | None
|
Optional additional fields merged into the |
None
|
Returns:
| Type | Description |
|---|---|
A2AMessage
|
A2AMessage with the OAuth pause embedded as a DataPart |
create_progress_message(content: str, status: ProgressStatus | str, progress: ProgressData, metadata: dict[str, Any] | None = None, session_id: str | None = None) -> A2AMessage
staticmethod
Create an A2A Message with progress message embedded as a metadata part.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
content
|
str
|
Human-readable progress message |
required |
status
|
ProgressStatus | str
|
Progress status (ProgressStatus enum or string) |
required |
progress
|
ProgressData
|
Progress data dictionary |
required |
metadata
|
dict[str, Any] | None
|
Optional additional metadata |
None
|
session_id
|
str | None
|
Optional session ID |
None
|
Returns:
| Type | Description |
|---|---|
A2AMessage
|
A2AMessage with progress embedded as a DataPart |
create_write_confirmation_required_message(content: str, call_id: str, tool_name: str, session_id: str | None = None, metadata: dict[str, Any] | None = None) -> A2AMessage
staticmethod
Create an A2A Message pausing the run for a destructive-tool confirmation.
Under ACP this pause was its own event type, tool.write_confirmation_required;
A2A has no event vocabulary, so it travels as a NovaOS metadata part under
:data:NOVAOS_PAUSE_WRITE_CONFIRMATION_KEY, read on the platform side by
extractPausePayloads. The Temporal worker mints its resume execution
token bound to call_id in its background execution path, so an agent
that omits it turns a confirmable write into a failed run rather than a
blanket write authorization — always pass the real call_id the tool
call carried, never a placeholder.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
content
|
str
|
Human-readable message content (e.g. "Confirm sending this email?") |
required |
call_id
|
str
|
The id of the pending tool call the confirmation is bound to. |
required |
tool_name
|
str
|
The name of the tool awaiting confirmation. |
required |
session_id
|
str | None
|
Optional session ID for multi-turn conversations. |
None
|
metadata
|
dict[str, Any] | None
|
Optional additional fields merged into the
|
None
|
Returns:
| Type | Description |
|---|---|
A2AMessage
|
A2AMessage with the write-confirmation pause embedded as a DataPart |
embed_error_message(message: A2AMessage, error: ErrorMessage) -> A2AMessage
staticmethod
Embed an error message in an existing A2A Message as a metadata part.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
message
|
A2AMessage
|
Existing A2A Message |
required |
error
|
ErrorMessage
|
Error message to embed |
required |
Returns:
| Type | Description |
|---|---|
A2AMessage
|
New A2AMessage with error appended as a DataPart |
embed_hitl_message(message: A2AMessage, hitl: HITLMessage) -> A2AMessage
staticmethod
Embed a HITL message in an existing A2A Message as a metadata part.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
message
|
A2AMessage
|
Existing A2A Message |
required |
hitl
|
HITLMessage
|
HITL message to embed |
required |
Returns:
| Type | Description |
|---|---|
A2AMessage
|
New A2AMessage with HITL appended as a DataPart |
embed_hitl_response(message: A2AMessage, hitl_response: HITLResponse) -> A2AMessage
staticmethod
Embed a HITL response in an existing A2A Message as a metadata part.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
message
|
A2AMessage
|
Existing A2A Message |
required |
hitl_response
|
HITLResponse
|
HITL response to embed |
required |
Returns:
| Type | Description |
|---|---|
A2AMessage
|
New A2AMessage with HITL response appended as a DataPart |
embed_progress_message(message: A2AMessage, progress: ProgressMessage) -> A2AMessage
staticmethod
Embed a progress message in an existing A2A Message as a metadata part.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
message
|
A2AMessage
|
Existing A2A Message |
required |
progress
|
ProgressMessage
|
Progress message to embed |
required |
Returns:
| Type | Description |
|---|---|
A2AMessage
|
New A2AMessage with progress appended as a DataPart |
novaos_client_sdk.message_parser
MessageParser utility for parsing and extracting standardized messages.
Supports three message formats:
- A2A (novaos_client_sdk.a2a_types.A2AMessage, spec v1.0.1) where
metadata is carried as a DataPart whose part-level metadata tags it
with NOVAOS_METADATA_PART_KEY: "application/vnd.novaos.metadata+json".
This is what MessageBuilder produces post-migration.
- ACP-shaped messages where metadata is a part with
content_type="application/vnd.novaos.metadata+json" carrying a JSON string.
- Legacy novaos_client_sdk.models.Message (dataclass) where metadata is
stored in a .metadata dict attribute.
Parsing stays duck-typed and format-tolerant on purpose: a parser is read-side, and the SDK reads messages produced by components that migrate on their own schedules (the worker rewrite, and the per-agent rollout that followed it). The builder is a clean A2A-only cutover; the parser is not.
MessageParser
Utility class for parsing and extracting standardized messages from A2A Messages.
extract_all(message: Any) -> dict[str, Any]
staticmethod
Extract all standardized messages from a message.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
message
|
Any
|
A2A, ACP-shaped, or legacy message to parse |
required |
Returns:
| Type | Description |
|---|---|
dict[str, Any]
|
Dictionary with keys 'hitl', 'error', 'progress', 'hitl_response' |
dict[str, Any]
|
(only present if found) |
extract_error_message(message: Any) -> ErrorMessage | None
staticmethod
Extract error message from message metadata.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
message
|
Any
|
A2A, ACP-shaped, or legacy message to parse |
required |
Returns:
| Type | Description |
|---|---|
ErrorMessage | None
|
ErrorMessage if found, None otherwise |
extract_hitl_message(message: Any) -> HITLMessage | None
staticmethod
Extract HITL message from message metadata.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
message
|
Any
|
A2A, ACP-shaped, or legacy message to parse |
required |
Returns:
| Type | Description |
|---|---|
HITLMessage | None
|
HITLMessage if found, None otherwise |
extract_hitl_response(message: Any) -> HITLResponse | None
staticmethod
Extract HITL response from message metadata.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
message
|
Any
|
A2A, ACP-shaped, or legacy message to parse |
required |
Returns:
| Type | Description |
|---|---|
HITLResponse | None
|
HITLResponse if found, None otherwise |
extract_progress_message(message: Any) -> ProgressMessage | None
staticmethod
Extract progress message from message metadata.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
message
|
Any
|
A2A, ACP-shaped, or legacy message to parse |
required |
Returns:
| Type | Description |
|---|---|
ProgressMessage | None
|
ProgressMessage if found, None otherwise |
is_error_message(message: Any) -> bool
staticmethod
Check if message contains an error message.
is_hitl_message(message: Any) -> bool
staticmethod
Check if message contains a HITL message.
is_hitl_response(message: Any) -> bool
staticmethod
Check if message contains a HITL response.
is_progress_message(message: Any) -> bool
staticmethod
Check if message contains a progress message.
novaos_client_sdk.message_validator
MessageValidator utility for validating standardized message structures.
MessageValidator
Utility class for validating standardized message structures.
get_validation_errors(data: dict[str, Any], message_type: str | None = None) -> list[str]
staticmethod
Get validation errors for a message dictionary.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
dict[str, Any]
|
Dictionary to validate |
required |
message_type
|
str | None
|
Optional message type hint ('hitl', 'error', 'progress', 'hitl_response') |
None
|
Returns:
| Type | Description |
|---|---|
list[str]
|
List of validation error messages |
validate_a2a_envelope(message: Any) -> bool
staticmethod
Validate the A2A-specific envelope fields of a message.
A2A spec v1.0.1 requires messageId, role and at least one part
on every Message. ACP had no such envelope, so this check only
applies to messages that present as A2A; anything else passes here,
leaving format-agnostic validation to :meth:validate_message.
(v0.3 also required kind == "message". v1.0 dropped that field —
see F18 — so the A2A-shape test is structural now, not a discriminator
lookup.)
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
message
|
Any
|
Message to validate |
required |
Returns:
| Type | Description |
|---|---|
bool
|
True if the message is not A2A-shaped, or is a valid A2A envelope |
validate_error_message(data: dict[str, Any]) -> bool
staticmethod
Validate error message structure.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
dict[str, Any]
|
Dictionary to validate |
required |
Returns:
| Type | Description |
|---|---|
bool
|
True if valid, False otherwise |
validate_hitl_message(data: dict[str, Any]) -> bool
staticmethod
Validate HITL message structure.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
dict[str, Any]
|
Dictionary to validate |
required |
Returns:
| Type | Description |
|---|---|
bool
|
True if valid, False otherwise |
validate_hitl_response(data: dict[str, Any]) -> bool
staticmethod
Validate HITL response structure.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
dict[str, Any]
|
Dictionary to validate |
required |
Returns:
| Type | Description |
|---|---|
bool
|
True if valid, False otherwise |
validate_message(message: Any) -> bool
staticmethod
Validate a message and any embedded standardized messages.
Supports A2A messages (novaos_client_sdk.a2a_types.A2AMessage),
ACP-shaped messages, and novaos_client_sdk.models.Message.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
message
|
Any
|
A2A, ACP-shaped, or legacy message to validate |
required |
Returns:
| Type | Description |
|---|---|
bool
|
True if valid, False otherwise |
validate_progress_message(data: dict[str, Any]) -> bool
staticmethod
Validate progress message structure.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
dict[str, Any]
|
Dictionary to validate |
required |
Returns:
| Type | Description |
|---|---|
bool
|
True if valid, False otherwise |
Exceptions
novaos_client_sdk.exceptions
Exception classes for NovaOS Client SDK.
Hierarchy strategy
Before the A2A migration this hierarchy was rooted in ACPClientError and every
subclass carried an ACP prefix. This design chose neither of the two obvious
options — it is not a mechanical ACP* → A2A* rename, and it is not a
hierarchy rooted in A2A. Both would have been wrong for the same reason: these
exceptions are overwhelmingly not protocol errors.
Ten SDK modules raise from this hierarchy, and only client.py and
discovery.py talk to an agent over the wire protocol at all. memory.py,
mcp.py, llm.py, heartbeat.py, observability.py, gateway.py and
dns.py are plain NovaOS platform HTTP clients. Calling a memory-service
socket failure an A2AConnectionError would be as untrue as calling it an
ACPConnectionError was — the prefix was already a misnomer, and migrating it
to A2A would have re-entrenched that misnomer under a name we could no longer
blame on history.
So the root is protocol-neutral (NovaOSError), and A2A gets a real,
narrow subtree of its own (A2AProtocolError) holding exactly the errors the
A2A specification actually defines. Two properties follow:
- The genuinely-A2A errors have somewhere correct to live. A2A v1.0.1 defines
nine protocol error codes (
-32001…-32009); under a flatA2A*rename those would have had to fight the generic HTTP errors for names. - A future protocol migration touches only the
A2AProtocolErrorsubtree, never the platform errors — the same open/closed argument applied to the extension mechanism.
There is no back-compat shim and there are no deprecated ACP* aliases, per
the migration's no-backward-compatibility scope decision.
Liskov substitutability (an explicit LSP requirement)
Every subclass honors NovaOSError's contract, so code that catches the base
type is never surprised by a subclass:
- Constructor — every subclass accepts
(message)positionally andretry_after/detailsby keyword. Subclass-specific extras (field,status_code,agent_name, …) are keyword-only, so no subclass can reinterpret a positional argument the base gives a different meaning. That was a live defect before this hierarchy:ACPValidationError("m", 1.5)bound1.5tofieldwhileACPClientError("m", 1.5)bound it toretry_after. - Attributes —
message,retry_after,detailsand theis_retryableproperty are present on every subclass. Subclasses may refineis_retryable's value, never its type or its presence.
A2AProtocolError(message: str, *, error_code: int | None = None, task_id: str | None = None, retry_after: float | None = None, details: dict[str, Any] | None = None)
Bases: NovaOSError
Base for errors the A2A protocol itself defines.
Distinct from the platform errors above: an A2AProtocolError means a
remote agent replied with one of the A2A specification's own JSON-RPC error
codes, not that an HTTP call failed. Subclasses set :attr:error_code to
the spec's numeric code; see :data:A2A_ERROR_SPEC_VERSION for the pinned
release.
One code in this subtree, :class:InvalidAgentResponseError (-32006),
is not only something a peer can send to this SDK — it is also something
this SDK raises about a peer's reply, when that reply cannot be a valid
A2A response at all. That is still an A2AProtocolError rather
than a platform error: the defect is the same either way — a response the
spec does not allow — whether the peer reported it as an explicit JSON-RPC
error or simply sent malformed data.
Standard JSON-RPC transport errors (-32700 … -32603) are deliberately
not modelled here — they are not A2A-specific, and the SDK surfaces them as
:class:NovaOSServerError / :class:NovaOSValidationError alongside every
other transport failure.
Initialize an A2A protocol error.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
message
|
str
|
Human-readable error message |
required |
error_code
|
int | None
|
The JSON-RPC code actually received. Defaults to the
code this class represents, so concrete subclasses need not pass
it. Supply it when the code has no dedicated subclass — that is
the only way :class: |
None
|
task_id
|
str | None
|
Optional A2A |
None
|
retry_after
|
float | None
|
Optional retry delay in seconds |
None
|
details
|
dict[str, Any] | None
|
Optional additional error details, e.g. the JSON-RPC
|
None
|
AgentCardSignatureError(message: str, *, retry_after: float | None = None, details: dict[str, Any] | None = None)
Bases: NovaOSError
Base for Agent Card JWS signature-verification failures.
Raised only when a caller opts into verification —
:func:novaos_client_sdk.a2a_card_signing.verify_agent_card_signatures and
:class:novaos_client_sdk.discovery.AgentDiscoveryService never check
signatures unless asked, so an unsigned card from a trusted source keeps
working for every existing caller.
Deliberately not an :class:A2AProtocolError: the A2A specification does
not define signature verification as one of its JSON-RPC error codes, so
filing this under the protocol subtree would make a local trust decision
look like something a remote agent reported over the wire — the same
reasoning that keeps :class:SealedEnvelopeError out of that subtree too.
ContentTypeNotSupportedError(message: str, *, error_code: int | None = None, task_id: str | None = None, retry_after: float | None = None, details: dict[str, Any] | None = None)
ExtendedAgentCardNotConfiguredError(message: str, *, error_code: int | None = None, task_id: str | None = None, retry_after: float | None = None, details: dict[str, Any] | None = None)
ExtensionSupportRequiredError(message: str, *, error_code: int | None = None, task_id: str | None = None, retry_after: float | None = None, details: dict[str, Any] | None = None)
InvalidAgentCardSignatureError(message: str, *, retry_after: float | None = None, details: dict[str, Any] | None = None)
Bases: AgentCardSignatureError
Raised when a card carries one or more signatures and none of them verify.
Covers a tampered card, a signature from a key the caller's JWKS does not have, and one produced with an algorithm outside the caller's allowlist alike — all three mean the same thing to a caller: the card's declared content is not backed by a valid signature from a key it trusts.
InvalidAgentResponseError(message: str, *, error_code: int | None = None, task_id: str | None = None, retry_after: float | None = None, details: dict[str, Any] | None = None)
MissingExecutionTokenError(message: str = DEFAULT_MESSAGE, *, retry_after: float | None = None, details: dict[str, Any] | None = None)
Bases: NovaOSAuthenticationError
Raised when an execution token is required but not present.
This typically means the agent is running outside the auth-proxy (e.g. local
dev, direct HTTP, or a test harness) and no X-NovaOS-Execution-Token header
was injected. Set the environment variable NOVAOS_EXECUTION_TOKEN or pass
an explicit token to resolve this.
Initialize missing token error.
message defaults to :attr:DEFAULT_MESSAGE. retry_after and
details are accepted for base-class substitutability (LSP) even
though this error is not transient — before this hierarchy this subclass took only
message, so re-raising it through generic base-typed error handling
raised TypeError.
NoAgentCardSignatureError(message: str, *, retry_after: float | None = None, details: dict[str, Any] | None = None)
Bases: AgentCardSignatureError
Raised when verification is requested but the card carries no signatures.
Distinct from :class:InvalidAgentCardSignatureError on purpose: an
unsigned card is not evidence of tampering, only of a publisher that has not
adopted signing — while a signed-but-invalid card means either tampering or
a misconfigured verifier. An operator needs to tell those two apart rather
than see one "verification failed" error for both.
NovaOSAgentNotFoundError(message: str, *, agent_name: str | None = None, retry_after: float | None = None, details: dict[str, Any] | None = None)
Bases: NovaOSError
Raised when an agent is not found.
Initialize agent not found error.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
message
|
str
|
Human-readable error message |
required |
agent_name
|
str | None
|
Optional name of the agent that was not found |
None
|
retry_after
|
float | None
|
Optional retry delay in seconds |
None
|
details
|
dict[str, Any] | None
|
Optional additional error details |
None
|
NovaOSAuthenticationError(message: str, *, retry_after: float | None = None, details: dict[str, Any] | None = None)
NovaOSConnectionError(message: str, *, retry_after: float | None = None, details: dict[str, Any] | None = None)
NovaOSDNSAgentNotFoundError(message: str, *, agent_name: str | None = None, retry_after: float | None = None, details: dict[str, Any] | None = None)
Bases: NovaOSDNSError
Raised when an agent is not found in the DNS service.
Initialize DNS agent not found error.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
message
|
str
|
Human-readable error message |
required |
agent_name
|
str | None
|
Optional name of the agent that was not found |
None
|
retry_after
|
float | None
|
Optional retry delay in seconds |
None
|
details
|
dict[str, Any] | None
|
Optional additional error details |
None
|
NovaOSDNSConnectionError(message: str, *, retry_after: float | None = None, details: dict[str, Any] | None = None)
Bases: NovaOSDNSError
Raised when a connection to the DNS service fails.
Retryable, matching :class:NovaOSConnectionError. dns.py raises this
for connect errors, timeouts and DNS-service 5xx responses — all transient
— so the DNS subtree must not report them as permanent when the platform
subtree reports the identical conditions as retryable.
NovaOSDNSError(message: str, *, retry_after: float | None = None, details: dict[str, Any] | None = None)
NovaOSDNSRateLimitError(message: str, *, retry_after: float | None = None, details: dict[str, Any] | None = None)
Bases: NovaOSDNSError
Raised when the DNS service rate-limits a lookup.
The DNS mirror of :class:NovaOSRateLimitError. Exists so the 429 branch of
dns.py can honor the Retry-After header it already parses: before
this class, that branch stored retry_after on an error whose
is_retryable was False, telling the caller "here is when to retry"
and "do not retry" at the same time.
NovaOSError(message: str, *, retry_after: float | None = None, details: dict[str, Any] | None = None)
Bases: Exception
Base exception for every error raised by the NovaOS client SDK.
Protocol-neutral by design — see the module docstring. Catching this type
catches everything the SDK raises, including :class:A2AProtocolError.
Initialize the error.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
message
|
str
|
Human-readable error message |
required |
retry_after
|
float | None
|
Optional retry delay in seconds |
None
|
details
|
dict[str, Any] | None
|
Optional additional error details |
None
|
is_retryable: bool
property
Whether this error is transient and the request can be retried.
NovaOSRateLimitError(message: str, *, retry_after: float | None = None, details: dict[str, Any] | None = None)
NovaOSServerError(message: str, *, status_code: int | None = None, retry_after: float | None = None, details: dict[str, Any] | None = None)
Bases: NovaOSError
Raised when a server returns an error.
Initialize server error.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
message
|
str
|
Human-readable error message |
required |
status_code
|
int | None
|
Optional HTTP status code |
None
|
retry_after
|
float | None
|
Optional retry delay in seconds |
None
|
details
|
dict[str, Any] | None
|
Optional additional error details |
None
|
NovaOSValidationError(message: str, *, field: str | None = None, reason: str | None = None, retry_after: float | None = None, details: dict[str, Any] | None = None)
Bases: NovaOSError
Raised when request validation fails.
Initialize validation error.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
message
|
str
|
Human-readable error message |
required |
field
|
str | None
|
Optional field name that failed validation |
None
|
reason
|
str | None
|
Optional reason for validation failure |
None
|
retry_after
|
float | None
|
Optional retry delay in seconds |
None
|
details
|
dict[str, Any] | None
|
Optional additional error details |
None
|
PushNotificationNotSupportedError(message: str, *, error_code: int | None = None, task_id: str | None = None, retry_after: float | None = None, details: dict[str, Any] | None = None)
RecipientKeyUnavailableError(message: str, *, retry_after: float | None = None, details: dict[str, Any] | None = None)
Bases: SealedEnvelopeError
Raised when this pod holds no private key able to open an envelope.
The ordinary causes are operational rather than adversarial: the key rotated and this envelope was sealed to a predecessor that has since aged out of its overlap window, the key was revoked after a suspected compromise, or the key volume is not mounted at all. All three are reported the same way, because the pod cannot tell them apart — only that the key it is being asked for is not one it has.
SealedEnvelopeError(message: str, *, retry_after: float | None = None, details: dict[str, Any] | None = None)
Bases: NovaOSError
Base for failures of NovaOS's own secret-delivery encryption.
A2A gives the request metadata map no confidentiality of its own, so NovaOS seals each secret
value into an envelope only the destination pod can open. This subtree is
what that layer raises.
Deliberately not an :class:A2AProtocolError: the sealed envelope is a
NovaOS construction, not something the A2A specification defines, and it has
no spec error code. Filing it under the protocol subtree would make a
NovaOS-only failure look like something a remote agent reported.
Never retryable, and never carries plaintext, key material or ciphertext in
its message or details. A message that said which part of the
decryption failed would be a decryption oracle; one that echoed the value
would defeat the point of encrypting it.
SealedEnvelopeFormatError(message: str, *, retry_after: float | None = None, details: dict[str, Any] | None = None)
Bases: SealedEnvelopeError
Raised when a sealed envelope is malformed, or is not one this SDK speaks.
Separate from the base class because the two mean different things to whoever reads the log. A format error is a version or wiring problem — a worker sealing under a suite this pod does not implement, a truncated payload — and is fixed by aligning the two sides. The base class means the envelope was well-formed and still did not open, which is either the wrong key or an envelope that does not belong to this delivery.
SecretNotDeliveredError(message: str, *, retry_after: float | None = None, details: dict[str, Any] | None = None)
Bases: NovaOSError
Raised when a skill asks for a secret this request did not carry.
Deliberately not a :class:SealedEnvelopeError: nothing failed to open,
and nothing cryptographic happened at all. Either the run delivered no
secret under that name, or the user-context/v1 extension was never
activated for this request — the latter being what a skill sees when it is
invoked by a caller that does not seal secrets, such as an agent-to-agent
call or a hand-written curl. Filing it under the envelope subtree would
send whoever reads the log looking for a key problem that does not exist.
:attr:details names what was delivered, because the difference between
"this instance declares no such secret" and "delivery is not working at
all" is otherwise invisible from the message alone.
TaskNotCancelableError(message: str, *, error_code: int | None = None, task_id: str | None = None, retry_after: float | None = None, details: dict[str, Any] | None = None)
TaskNotFoundError(message: str, *, error_code: int | None = None, task_id: str | None = None, retry_after: float | None = None, details: dict[str, Any] | None = None)
UnsupportedOperationError(message: str, *, error_code: int | None = None, task_id: str | None = None, retry_after: float | None = None, details: dict[str, Any] | None = None)
VersionNotSupportedError(message: str, *, error_code: int | None = None, task_id: str | None = None, retry_after: float | None = None, details: dict[str, Any] | None = None)
a2a_error_for_code(code: int) -> type[A2AProtocolError]
Return the exception class for an A2A JSON-RPC error code.
Unknown codes fall back to :class:A2AProtocolError rather than raising, so
an agent on a newer spec release degrades to the base type instead of
breaking the caller.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
code
|
int
|
JSON-RPC |
required |
Returns:
| Type | Description |
|---|---|
type[A2AProtocolError]
|
The matching :class: |
a2a_error_from_json_rpc(error: Mapping[str, Any], *, task_id: str | None = None) -> A2AProtocolError
Build the right :class:A2AProtocolError from a JSON-RPC error object.
Prefer this over calling :func:a2a_error_for_code and constructing the
class yourself: it always records the code that actually came off the wire,
including when the code has no dedicated subclass and the lookup falls back
to :class:A2AProtocolError. Constructing the fallback by hand is the easy
way to end up with error_code is None on exactly the errors you most need
to identify.
The JSON-RPC error.data payload — which the spec allows to be any JSON
value, not just an object — is always stored as details["data"] so the
shape of details does not vary with the payload's type.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
error
|
Mapping[str, Any]
|
A JSON-RPC |
required |
task_id
|
str | None
|
Optional A2A |
None
|
Returns:
| Type | Description |
|---|---|
A2AProtocolError
|
An instance of the subclass matching |
A2AProtocolError
|
class: |
error_from_json_rpc(error: Mapping[str, Any], *, task_id: str | None = None) -> NovaOSError
Build the right exception for a JSON-RPC error object from either layer.
Prefer this over :func:a2a_error_from_json_rpc at a transport boundary,
where the code that arrives has not been vetted: a peer answers A2A's own
codes and JSON-RPC's standard ones over the same channel, and only the
former belong in the :class:A2AProtocolError subtree. Routing everything
through the A2A constructor would classify "method not found" as an A2A
protocol failure.
An unrecognized code still goes to :class:A2AProtocolError, which
carries it: a code outside both tables is most likely a newer spec
revision's, and the base class is where such a thing degrades to.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
error
|
Mapping[str, Any]
|
A JSON-RPC |
required |
task_id
|
str | None
|
Optional A2A |
None
|
Returns:
| Type | Description |
|---|---|
NovaOSError
|
A platform error for the standard JSON-RPC codes, an |
NovaOSError
|
class: |
jsonrpc_error_code(error: BaseException) -> int | None
Return the JSON-RPC code an error came from, whichever layer it landed in.
A caller that wants to branch on the code — "did this agent simply not
implement the method?" is the common case — should not have to know which
branch of the hierarchy the code put the exception in. This reads
:attr:A2AProtocolError.error_code when it is there and
:data:JSONRPC_CODE_DETAIL_KEY otherwise.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
error
|
BaseException
|
Any exception. |
required |
Returns:
| Type | Description |
|---|---|
int | None
|
The code, or |
Platform service clients
These are part of the core package — no extra install required — but each talks to a specific platform service, so you'll typically only use the ones your agent actually depends on.
novaos_client_sdk.memory
Memory Service client for NovaOS agents.
Provides a typed async client for the NovaOS Memory Service MCP endpoint.
.. admonition:: Architecture — Named I2 Exception
The Memory Service is called directly by this client (not via the MCP Gateway). This is a deliberate, documented exception to platform invariant I2 ("single governed egress; agents call internal services through the MCP Gateway only — never directly").
Design rationale for the direct path:
- Policy-hop cost without benefit — Routing through the MCP Gateway
would add a policy evaluation hop (OPA + rate limits) without those
controls applying to the Memory Service's own endpoints. The gateway
would proxy the
/api/v1/mcpdispatch envelope opaquely with no additional enforcement. - Performance — Durable-memory lookups sit on the critical path of every agent turn. An unnecessary gateway hop adds p99 latency for all agent invocations.
- Protocol ownership — Memory Service predates the MCP Gateway's role as the general egress point. Moving it behind the gateway is tracked as a long-term architectural goal, not a near-term requirement.
Authorization model (reconciliation with OPA policy):
- The caller must pass
headers=ctx.to_auth_headers(), which forwardsAuthorization: Bearer <execution_token>to the Memory Service. - The Memory Service validates the same RS256 execution-token shape
(issuer, expiry, core claims including
tenant_id) as the MCP Gateway does. MCP Gateway OPA evaluation and gateway rate limits are not in this path. - Scope write-restrictions (R1: agents cannot write to
userscope; R2:tenantscope is admin-only) are enforced by the Memory Service's own policy layer. - Tenant isolation — all reads and writes include
tenant_idfrom the token claims, enforced at the Memory Service data layer. - Memory Service may also enforce memory IAM scopes from the token's
scopesclaim (memory:read:*/memory:write:*) via its own policy layer, separately from MCP Gateway OPA.
Known policy gap — MCP Gateway-managed controls (per-agent capability allow-lists, audit enrichment, gateway rate limits) are not applied to Memory Service requests. A policy change in the gateway does not automatically restrict memory access. Because execution tokens are short-lived, gateway policy changes do not affect an already-minted token until expiry or run termination. Operator runbooks must explicitly revoke the agent's execution token to enforce restrictions immediately.
Canonical memory access — MemoryClient is the sole supported path
for durable semantic memory via direct HTTP to the Memory Service.
This exception is formally recorded in the platform's architecture documentation, §I2.
The client wraps the POST /api/v1/mcp dispatch endpoint, providing typed methods
for each MCP tool: query, write, forget, hint, and
assemble_context.
Example
from novaos_client_sdk.context import ExecutionContext
from novaos_client_sdk.memory import MemoryClient
ctx = ExecutionContext.from_headers(request.headers)
async with MemoryClient(headers=ctx.to_auth_headers()) as mem:
# Write agent-scoped operational knowledge
result = await mem.write(
scope="agent",
content="Reporting API requires ISO 8601 date ranges",
tags=["api", "reporting"],
)
print(result.memory_id)
# Submit a hint for user-memory consolidation
hint_result = await mem.hint(
conversation_id="conv-123",
hint="User expressed preference for dark mode",
)
print(hint_result.hint_id) # queued, not committed
# Query memories
query_result = await mem.query(scope="user", query="ui preferences")
for item in query_result.items:
print(f"{item.memory_id}: {item.content}")
# Assemble context for agent reasoning
context = await mem.assemble_context(query="current task context")
print(f"Got {len(context.durable_memories)} durable memories")
MemoryType = Literal['semantic', 'episodic', 'procedural']
module-attribute
Optional kind of a memory. Opt-in on write and on query/assemble filters.
AssembleResult(durable_memories: list[MemoryItem] = list(), live_messages: list[LiveMessage] = list(), agent_memories: list[MemoryItem] = list(), run_data: dict[str, Any] | None = None, sources_consulted: list[str] = list(), sources_skipped: list[str] = list(), partial: bool = False, retrieval_id: str | None = None)
dataclass
Result of a memory.assemble call.
Combines memories from multiple scopes into a single context bundle for agent reasoning.
Attributes:
| Name | Type | Description |
|---|---|---|
durable_memories |
list[MemoryItem]
|
User-scoped durable memories. |
live_messages |
list[LiveMessage]
|
Recent conversation messages. |
agent_memories |
list[MemoryItem]
|
Agent-scoped durable memories. |
run_data |
dict[str, Any] | None
|
Ephemeral run-scoped data (dict or None). |
sources_consulted |
list[str]
|
Which sources were queried. |
sources_skipped |
list[str]
|
Which sources were skipped (missing capabilities/IDs). |
partial |
bool
|
True if some sources failed. |
retrieval_id |
str | None
|
Opaque ID for ExplainRetrieval lookups. |
ForgetResult(deleted_count: int = 0, delete_event_id: str = '')
dataclass
Result of a memory.forget call.
Attributes:
| Name | Type | Description |
|---|---|---|
deleted_count |
int
|
Number of memories deleted. |
delete_event_id |
str
|
Opaque event ID for audit trail. |
HintResult(hint_id: str = '', status: str = 'accepted', message: str = '')
dataclass
Result of a memory.hint call.
Attributes:
| Name | Type | Description |
|---|---|---|
hint_id |
str
|
ID of the enqueued consolidation hint. |
status |
str
|
Acceptance status (typically "accepted"). |
message |
str
|
Human-readable status message. |
HintStatus(hint_id: str = '', status: str = 'pending', submitted_at: str | None = None, resolved_at: str | None = None)
dataclass
Outcome of a consolidation hint this agent submitted.
Attributes:
| Name | Type | Description |
|---|---|---|
hint_id |
str
|
ID returned by |
status |
str
|
|
submitted_at |
str | None
|
When the service accepted the hint (ISO 8601), if known. |
resolved_at |
str | None
|
When consolidation resolved it (ISO 8601); |
LiveMessage(message_id: str = '', role: str = '', content: str = '', timestamp: str = '')
dataclass
A live conversation message from the Conversation Store.
Attributes:
| Name | Type | Description |
|---|---|---|
message_id |
str
|
Unique message identifier. |
role |
str
|
Message role (user, assistant, system). |
content |
str
|
Message text content. |
timestamp |
str
|
ISO-8601 timestamp. |
MemoryClient(base_url: str | None = None, headers: dict[str, str] | None = None, timeout: float = 30.0, max_retries: int = 3, circuit_breaker_threshold: int = 5, circuit_breaker_reset_s: float = 30.0, *, client: httpx.AsyncClient | None = None)
Async client for the NovaOS Memory Service.
Communicates with the Memory Service's /mcp dispatch endpoint using
the MCP tool protocol (tool name + payload + context).
Includes built-in retry with exponential backoff for transient errors (429, 502, 503, 504) and a circuit breaker for fail-fast when the service is down.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
base_url
|
str | None
|
Memory Service URL. If None, reads from |
None
|
headers
|
dict[str, str] | None
|
Additional HTTP headers (typically includes |
None
|
timeout
|
float
|
Request timeout in seconds (default: 30). |
30.0
|
max_retries
|
int
|
Maximum total attempts for retryable errors (default: 3).
Always issues at least one attempt, even when set to |
3
|
circuit_breaker_threshold
|
int
|
Failures before circuit opens (default: 5). |
5
|
circuit_breaker_reset_s
|
float
|
Seconds before half-open probe (default: 30). |
30.0
|
client
|
AsyncClient | None
|
Optional pre-configured httpx.AsyncClient to use instead of constructing one internally. Useful for tests and for callers who want to share a connection pool or attach custom transports/interceptors. If provided, MemoryClient will NOT close it on aexit — the caller retains ownership of its lifecycle. |
None
|
Example
assemble_context(query: str, *, top_k: int = 10, live_message_limit: int = 20, token_budget: int | None = None, latency_budget_ms: int | None = None, memory_type: MemoryType | None = None, conversation_id: str | None = None, ctx: AgentExecutionContext | None = None, request_id: str | None = None, trace_id: str | None = None) -> AssembleResult
async
Assemble a unified context bundle from all available memory scopes.
Queries durable user memories, agent memories, live conversation
messages, and run data in parallel. Gracefully degrades if individual
sources fail (sets partial=True).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
query
|
str
|
Natural language query for semantic ranking. |
required |
top_k
|
int
|
Max durable memories per scope (1-100, default 10). |
10
|
live_message_limit
|
int
|
Max live messages to include (1-100, default 20). |
20
|
token_budget
|
int | None
|
Overall token budget for the assembled bundle, if the
caller has one — e.g. from |
None
|
latency_budget_ms
|
int | None
|
Per-turn latency budget in milliseconds, if the
caller has one. |
None
|
memory_type
|
MemoryType | None
|
Optional. Only include durable memories written with
this type ( |
None
|
conversation_id
|
str | None
|
Optional. Restrict durable recall to memories written under this conversation id (similarity-ranked). Opt-in; omitted: recall spans all conversations as before. |
None
|
ctx
|
AgentExecutionContext | None
|
The calling agent's execution context. Supplies
|
None
|
request_id
|
str | None
|
Optional request ID for tracing. |
None
|
trace_id
|
str | None
|
Optional trace ID for correlation. |
None
|
Returns:
| Type | Description |
|---|---|
AssembleResult
|
AssembleResult with memories from all scopes and availability metadata. |
Note
The Memory Service scopes live messages to the caller's
conversation from its own token claims, not from anything in this
call's payload. conversation_id here is a different thing: an
opt-in recall filter over durable memories written under that
conversation id, not the conversation the live messages come from.
close() -> None
async
Close the underlying HTTP client.
forget(scope: str, memory_id: str, *, reason: str = 'agent-requested', idempotency_key: str | None = None, request_id: str | None = None, trace_id: str | None = None) -> ForgetResult
async
Delete a specific memory by ID.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
scope
|
str
|
Memory scope ( |
required |
memory_id
|
str
|
ID of the memory to delete. |
required |
reason
|
str
|
Human-readable reason for deletion (1-500 chars). Stored
in the audit trail. Defaults to |
'agent-requested'
|
idempotency_key
|
str | None
|
Optional key for delete idempotency (24h TTL). |
None
|
request_id
|
str | None
|
Optional request ID for tracing. |
None
|
trace_id
|
str | None
|
Optional trace ID for correlation. |
None
|
Returns:
| Type | Description |
|---|---|
ForgetResult
|
ForgetResult with the delete count and event ID. |
Raises:
| Type | Description |
|---|---|
NovaOSValidationError
|
If |
NovaOSServerError
|
If the memory is not found (HTTP 404). |
hint(hint: str, *, conversation_id: str | None = None, ctx: AgentExecutionContext | None = None, priority: str = 'normal', tags: list[str] | None = None, context: str | None = None, request_id: str | None = None, trace_id: str | None = None) -> HintResult
async
Submit a consolidation hint for background fact extraction.
Hints are fire-and-forget — acceptance does not guarantee persistence.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
hint
|
str
|
Natural language hint about what to extract (max 8 KB). |
required |
conversation_id
|
str | None
|
ID of the conversation to consolidate from. If
omitted, derived from |
None
|
ctx
|
AgentExecutionContext | None
|
The calling agent's execution context, used to derive
|
None
|
priority
|
str
|
Hint priority ( |
'normal'
|
tags
|
list[str] | None
|
Optional tags for the resulting memories. |
None
|
context
|
str | None
|
Optional additional context for the consolidation worker. |
None
|
request_id
|
str | None
|
Optional request ID for tracing. |
None
|
trace_id
|
str | None
|
Optional trace ID for correlation. |
None
|
Returns:
| Type | Description |
|---|---|
HintResult
|
HintResult with the hint ID and acceptance status. |
Raises:
| Type | Description |
|---|---|
NovaOSValidationError
|
If |
hint_status(hint_id: str, *, request_id: str | None = None, trace_id: str | None = None) -> HintStatus
async
Read the outcome of a hint this agent submitted.
hint() only says the hint was accepted. This says whether
consolidation stored it: pending until the cycle resolves it, then
consolidated or dead_lettered; expired if it stayed pending
for over an hour. State is kept for 24 hours.
Raises:
| Type | Description |
|---|---|
NovaOSServerError
|
HTTP 404 if the hint is unknown, has aged out, or was submitted by a different agent. |
poll_proposal(proposal_id: str, *, request_id: str | None = None, trace_id: str | None = None) -> ProposalStatus
async
Check the status of an agent-scope write proposal.
After :meth:write returns a WriteResult with durable=False
(prop_-prefixed memory_id), call this method to learn whether
the AgentWriteGate has committed, rejected, or is still evaluating
the proposal.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
proposal_id
|
str
|
The |
required |
request_id
|
str | None
|
Optional request ID for tracing. |
None
|
trace_id
|
str | None
|
Optional trace ID for correlation. |
None
|
Returns:
| Type | Description |
|---|---|
ProposalStatus
|
ProposalStatus with the gate decision and, when committed, the |
ProposalStatus
|
durable |
Raises:
| Type | Description |
|---|---|
NovaOSServerError
|
If the proposal is not found (HTTP 404) or the service returns an error. |
query(scope: str, query: str, *, top_k: int = 10, filters: dict[str, Any] | None = None, memory_type: MemoryType | None = None, conversation_id: str | None = None, token_budget: int | None = None, latency_budget_ms: int | None = None, request_id: str | None = None, trace_id: str | None = None) -> QueryResult
async
Query memories by scope and semantic similarity.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
scope
|
str
|
Memory scope to query ( |
required |
query
|
str
|
Natural language query for semantic search. |
required |
top_k
|
int
|
Maximum number of results (1-100, default 10). |
10
|
filters
|
dict[str, Any] | None
|
Optional filters ( |
None
|
memory_type
|
MemoryType | None
|
Optional. Only return memories written with this type
( |
None
|
conversation_id
|
str | None
|
Optional. Recall durable memories written under this
conversation id, ranked by similarity to |
None
|
token_budget
|
int | None
|
Optional token budget for the result set. |
None
|
latency_budget_ms
|
int | None
|
Optional latency budget in milliseconds. |
None
|
request_id
|
str | None
|
Optional request ID for tracing. |
None
|
trace_id
|
str | None
|
Optional trace ID for correlation. |
None
|
Returns:
| Type | Description |
|---|---|
QueryResult
|
QueryResult with matching items and retrieval metadata. |
update(scope: str, memory_id: str, version: int, *, content: str | None = None, tags: list[str] | None = None, confidence: float | None = None, custom: dict[str, Any] | None = None, request_id: str | None = None, trace_id: str | None = None) -> UpdateResult
async
Update one of the agent's own memories in place.
Applies immediately (no proposal gate), so the next :meth:query returns
the new content. Only agent-scope memories can be updated; user
and tenant are refused for agent principals, and run/task
memories are ephemeral — :meth:forget and write a new one instead.
New content is screened for PII and secrets and rejected on a hit.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
scope
|
str
|
Memory scope — |
required |
memory_id
|
str
|
ID of the committed memory (the |
required |
version
|
int
|
The memory's |
required |
content
|
str | None
|
Optional new content (max 32 KB). |
None
|
tags
|
list[str] | None
|
Optional replacement tags (max 20, each max 64 chars). |
None
|
confidence
|
float | None
|
Optional new confidence score (0.0-1.0). |
None
|
custom
|
dict[str, Any] | None
|
Optional replacement custom metadata dict. |
None
|
request_id
|
str | None
|
Optional request ID for tracing. |
None
|
trace_id
|
str | None
|
Optional trace ID for correlation. |
None
|
Returns:
| Type | Description |
|---|---|
UpdateResult
|
UpdateResult with the memory's new version. |
Raises:
| Type | Description |
|---|---|
NovaOSValidationError
|
If none of |
NovaOSServerError
|
HTTP 409 on a stale |
write(scope: str, content: str, *, tags: list[str] | None = None, source: str | None = None, confidence: float | None = None, custom: dict[str, Any] | None = None, memory_type: MemoryType | None = None, ttl_seconds: int | None = None, classification: str | None = None, idempotency_key: str | None = None, ctx: AgentExecutionContext | None = None, request_id: str | None = None, trace_id: str | None = None) -> WriteResult
async
Write a new memory to agent or run scope.
Scope restrictions (platform policy):
agent— allowed; writes to the caller's own agent memory.run— allowed; writes ephemeral data scoped to the current run.user— blocked for agent principals (policy R1). Use :meth:hintto submit observations for the consolidation pipeline, which decides what to persist to user memory.tenant— blocked for agent principals (admin-only).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
scope
|
str
|
Memory scope ( |
required |
content
|
str
|
Memory content text (max 32 KB). |
required |
tags
|
list[str] | None
|
Optional tags for categorization (max 20, each max 64 chars). |
None
|
source
|
str | None
|
Optional source identifier. |
None
|
confidence
|
float | None
|
Optional confidence score (0.0-1.0). |
None
|
custom
|
dict[str, Any] | None
|
Optional custom metadata dict. |
None
|
memory_type
|
MemoryType | None
|
Optional kind of the memory ( |
None
|
ttl_seconds
|
int | None
|
Optional lifetime of the memory in seconds (the service enforces its own minimum and per-scope maximum and rejects a value outside them). Omitted: the scope's default lifetime applies. |
None
|
classification
|
str | None
|
Optional classification label ( |
None
|
idempotency_key
|
str | None
|
Optional key for write idempotency (24h TTL). If
omitted and |
None
|
ctx
|
AgentExecutionContext | None
|
The calling agent's execution context, used to derive
|
None
|
request_id
|
str | None
|
Optional request ID for tracing. |
None
|
trace_id
|
str | None
|
Optional trace ID for correlation. |
None
|
Returns:
| Type | Description |
|---|---|
WriteResult
|
WriteResult with the new memory ID and event metadata. |
Raises:
| Type | Description |
|---|---|
NovaOSServerError
|
If scope is |
MemoryItem(memory_id: str, scope: str, content: str, version: int = 1, metadata: MemoryMetadata = MemoryMetadata(), score: float | None = None)
dataclass
A single memory entry returned by query or assemble.
Attributes:
| Name | Type | Description |
|---|---|---|
memory_id |
str
|
Unique identifier for this memory. |
scope |
str
|
Memory scope (user, agent, run, tenant, platform). |
content |
str
|
The memory content text. |
version |
int
|
Version counter for this memory. |
metadata |
MemoryMetadata
|
Associated metadata (tags, source, timestamps). |
score |
float | None
|
Similarity to the query text (0 to 1) for durable scopes (user,
agent, tenant). Always |
MemoryMetadata(tags: list[str] = list(), source: str | None = None, conversation_id: str | None = None, confidence: float | None = None, salience: float | None = None, memory_type: str | None = None, created_at: str | None = None, updated_at: str | None = None, last_accessed_at: str | None = None, custom: dict[str, Any] | None = None)
dataclass
Metadata attached to a memory item.
ProposalStatus(proposal_id: str = '', status: str = '', reason: str | None = None, memory_id: str | None = None)
dataclass
Status of an agent-scope write proposal returned by the write-gate.
Attributes:
| Name | Type | Description |
|---|---|---|
proposal_id |
str
|
The |
status |
str
|
Gate decision — |
reason |
str | None
|
Rejection reason when |
memory_id |
str | None
|
The durable memory ID assigned after commit, or |
QueryResult(items: list[MemoryItem] = list(), retrieval_id: str = '', partial: bool = False, stores_queried: list[str] = list())
dataclass
Result of a memory.query call.
Attributes:
| Name | Type | Description |
|---|---|---|
items |
list[MemoryItem]
|
List of matching memory items. |
retrieval_id |
str
|
Opaque ID for ExplainRetrieval lookups. |
partial |
bool
|
True if some stores failed and results are incomplete. |
stores_queried |
list[str]
|
Which backing stores were consulted. |
UpdateResult(memory_id: str = '', scope: str = '', version: int = 0, update_event_id: str = '', updated_at: str = '')
dataclass
Result of a memory.update call.
Attributes:
| Name | Type | Description |
|---|---|---|
memory_id |
str
|
ID of the updated memory. |
scope |
str
|
Scope of the memory (always |
version |
int
|
The memory's new version. Pass it as |
update_event_id |
str
|
Opaque event ID for audit trail. |
updated_at |
str
|
ISO-8601 timestamp of the update. |
WriteResult(memory_id: str = '', scope: str = '', write_event_id: str = '', created_at: str = '', durable: bool = True)
dataclass
Result of a memory.write call.
Attributes:
| Name | Type | Description |
|---|---|---|
memory_id |
str
|
ID of the created/updated memory. For |
scope |
str
|
Scope the memory was written to. |
write_event_id |
str
|
Opaque event ID for audit trail. |
created_at |
str
|
ISO-8601 timestamp of creation. |
durable |
bool
|
Whether the write is already persisted. |
novaos_client_sdk.observability
Client for the NovaOS Gateway's run-observability endpoints.
Wraps two confirmed Gateway REST endpoints for inspecting and controlling a live agent run:
- EventIndex —
GET /api/v1/service/runs/:runId/events(service-account callers) orGET /api/v1/runs/:runId/events(user callers): history of a run's events. - DebugSession —
POST /api/v1/runs/:runId/debug/:action: pause / resume / step / abort control of a live run, part of the platform's "Agent Debugger Platform".
.. admonition:: Direct Gateway REST client, not an MCP-routed tool
Like :class:~novaos_client_sdk.memory.MemoryClient, this talks directly
to platform HTTP endpoints rather than going through the MCP Gateway's
/mcp/v1/tools/call JSON-RPC envelope used by
:class:~novaos_client_sdk.tools.ToolBindings — because these ARE Gateway
endpoints, not tools registered in the MCP tool registry.
.. admonition:: Auth requirements differ per endpoint — read before use
The platform serves run events on two routes. The service route
(/api/v1/service/runs/:runId/events) is guarded by service-account
bearer auth plus a revocation check (saAuthPreHandler +
assertSaNotRevoked) and answers 403 to a user token. The user route
(/api/v1/runs/:runId/events) serves a user's own runs.
get_run_events picks between them via its route argument; the
default "auto" tries the service route and falls back to the user
route on a 403. send_debug_control uses a more general bearer-auth
guard.
Endpoint contract: the events route and the debug-control
route are both shipped with full test coverage as part of the platform's
"Agent Debugger Platform". The EventIndex/DebugSession models
backing these live in the platform's shared schema. EventIndex stores
trace_id/span_id, but older platform builds do not return them on
either events route, so :attr:RunEvent.trace_id / span_id may be
None. sequence is returned by the user route on every build, and by
the service route only on newer builds.
⚠️ Both routes are registered on the Gateway without an /api/v1
prefix in the literal route-definition call — the prefix is applied
separately, where the route module is mounted
(server.register(debugControlRoutes, {prefix: "/api/v1", ...}) /
server.register(serviceRunsRoutes, {prefix: "/api/v1", ...})). Reading
only the route definition, as an earlier pass at this client did for the
debug-control path (caught in code review), silently produces the wrong
path. Verify against the server.register(...) call site, not just the
route definition, before trusting any route string pulled from this
codebase.
DebugControlResult(session_id: str = '', run_id: str = '', breakpoint_state: str = '', action: str = '')
dataclass
Result of a :meth:ObservabilityClient.send_debug_control call.
Attributes:
| Name | Type | Description |
|---|---|---|
session_id |
str
|
The DebugSession's ID. |
run_id |
str
|
The run this session controls. |
breakpoint_state |
str
|
State after the action was applied. |
action |
str
|
The action that was applied (echoed back). |
ObservabilityClient(base_url: str | None = None, headers: dict[str, str] | None = None, timeout: float = 30.0, max_retries: int = 3, circuit_breaker_threshold: int = 5, circuit_breaker_reset_s: float = 30.0, *, client: httpx.AsyncClient | None = None)
Async client for the NovaOS Gateway's run-observability endpoints.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
base_url
|
str | None
|
Gateway base URL. If |
None
|
headers
|
dict[str, str] | None
|
Additional HTTP headers — typically
|
None
|
timeout
|
float
|
Request timeout in seconds (default: 30). |
30.0
|
max_retries
|
int
|
Maximum retry attempts for :meth: |
3
|
circuit_breaker_threshold
|
int
|
Failures before circuit opens (default: 5). |
5
|
circuit_breaker_reset_s
|
float
|
Seconds before half-open probe (default: 30). |
30.0
|
client
|
AsyncClient | None
|
Optional pre-configured |
None
|
Example
get_run_events(run_id: str, *, after: str | None = None, limit: int | None = None, route: EventsRoute = 'auto') -> RunEventsPage
async
Fetch one page of a run's event history.
Retries on transient failures (429/502/503/504, network errors) with jittered backoff — this is a read-only GET, safe to retry.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
run_id
|
str
|
The run to fetch events for. |
required |
after
|
str | None
|
Opaque cursor from a prior :class: |
None
|
limit
|
int | None
|
Maximum events to return in this page (server-defined default/max apply if omitted). Only the service route honours it; the user route uses a fixed server-side page size. |
None
|
route
|
EventsRoute
|
|
'auto'
|
Returns:
| Type | Description |
|---|---|
RunEventsPage
|
One page of events plus a cursor for the next page. |
send_debug_control(run_id: str, action: DebugAction, *, metadata: dict[str, Any] | None = None) -> DebugControlResult
async
Send a debug-control action to a live run's DebugSession.
Not retried automatically, unlike :meth:get_run_events: a debug
action such as "step" is not guaranteed idempotent, so retrying
blindly on an ambiguous network failure (e.g. a timeout where the
server may have already applied the action) risks double-advancing
the run. If you know a specific failure happened strictly before the
server received the request, it's safe to retry yourself.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
run_id
|
str
|
The run whose DebugSession to control. |
required |
action
|
DebugAction
|
One of |
required |
metadata
|
dict[str, Any] | None
|
Optional metadata to attach to the control action. |
None
|
Returns:
| Type | Description |
|---|---|
DebugControlResult
|
The resulting session state after the action was applied. |
RunEvent(event_id: str = '', type: str = '', data: dict[str, Any] = dict(), timestamp: str = '', sequence: int | None = None, trace_id: str | None = None, span_id: str | None = None)
dataclass
A single event from a run's event history.
Attributes:
| Name | Type | Description |
|---|---|---|
event_id |
str
|
Unique identifier for this event. Empty on the user route, which does not return one. |
type |
str
|
Event type (e.g. tool call, status transition). |
data |
dict[str, Any]
|
Event payload — shape varies by |
timestamp |
str
|
ISO-8601 timestamp of the event. |
sequence |
int | None
|
Per-run ordering number, or |
trace_id |
str | None
|
Trace ID the platform stored for this event, if any. |
span_id |
str | None
|
Span ID the platform stored for this event, if any. |
RunEventsPage(events: list[RunEvent] = list(), next_cursor: str | None = None, request_id: str = '')
dataclass
One page of :meth:ObservabilityClient.get_run_events results.
Attributes:
| Name | Type | Description |
|---|---|---|
events |
list[RunEvent]
|
Events in this page, in server-returned order. |
next_cursor |
str | None
|
Opaque cursor for the next page, or |
request_id |
str
|
Request ID for tracing/support correlation. |
from_user_route_dict(data: dict[str, Any]) -> RunEventsPage
classmethod
Parse the user route's {"events": [...]} body.
That route has no cursor envelope, so next_cursor is the last
returned event's sequence (the same cursor format the service route
uses). It is None only once a page comes back empty, so a
while page.next_cursor loop ends with one empty request.
novaos_client_sdk.heartbeat
Client for the NovaOS agent-ops Heartbeat Schedule endpoints.
Lets a caller schedule proactive ("heartbeat") executions of an agent
instance — wiring a real Temporal Schedule that fires agentTaskWorkflow
on a cron cadence, instead of the agent faking recurrence with a
self-hosted POST /trigger/scheduled endpoint.
.. admonition:: Direct agent-ops REST client, not an MCP-routed tool
Like :class:~novaos_client_sdk.memory.MemoryClient and
:class:~novaos_client_sdk.observability.ObservabilityClient, this talks
directly to a platform HTTP service rather than going through the MCP
Gateway's /mcp/v1/tools/call JSON-RPC envelope — because these are
agent-ops control-plane routes, not tools registered in the MCP tool
registry.
.. admonition:: Auth — IAM bearer token, not a service-account header
Every agent-ops route is guarded by IAM-issued JWT bearer auth
(tokens validated against iamServiceIssuer /
iamServiceJwksUri / iamServiceAudience), not the service-account
convention some Gateway routes use. The SDK does not obtain, refresh, or
manage this token — the caller supplies it via headers (e.g.
{"Authorization": f"Bearer {token}"}"), matching every other client
in this SDK.
Endpoint contract: registered via
server.register(heartbeatRoutes, {prefix: "/api/v1/agent-ops", ...}).
Base URL env var (NOVAOS_AGENT_OPS_URL) matches the existing
galaxy-cli convention, not a name invented for this client.
.. admonition:: Why all four operations retry, unlike DebugSession's actions
:class:~novaos_client_sdk.observability.ObservabilityClient's
send_debug_control deliberately never auto-retries, because an action
like "step" is not guaranteed idempotent server-side. Every operation
here IS safe to retry on an ambiguous network failure, by construction on
the platform side: :meth:HeartbeatClient.create_config hits a DB unique
constraint on instance_id (a retried create after a successful-but-
unacknowledged first attempt gets a decisive 409, never a second
schedule), and :meth:HeartbeatClient.pause_config /
:meth:HeartbeatClient.resume_config no-op server-side when the target
status is already reached. So this client always retries on connection
errors and 429/502/503/504, the same policy
:meth:~novaos_client_sdk.observability.ObservabilityClient.get_run_events
uses for its read-only GET.
HeartbeatBudget(max_daily_runs: int = 0, max_daily_tokens: int = 0, max_daily_cost_usd: float = 0.0)
dataclass
Daily spend/usage cap for a heartbeat config's scheduled runs.
HeartbeatClient(base_url: str | None = None, headers: dict[str, str] | None = None, timeout: float = 30.0, max_retries: int = 3, circuit_breaker_threshold: int = 5, circuit_breaker_reset_s: float = 30.0, *, client: httpx.AsyncClient | None = None)
Async client for the NovaOS agent-ops Heartbeat Schedule endpoints.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
base_url
|
str | None
|
agent-ops base URL. If |
None
|
headers
|
dict[str, str] | None
|
Additional HTTP headers — must include
|
None
|
timeout
|
float
|
Request timeout in seconds (default: 30). |
30.0
|
max_retries
|
int
|
Maximum retry attempts for transient failures
(default: 3). See the module docstring for why every method
here retries, unlike |
3
|
circuit_breaker_threshold
|
int
|
Failures before circuit opens (default: 5). |
5
|
circuit_breaker_reset_s
|
float
|
Seconds before half-open probe (default: 30). |
30.0
|
client
|
AsyncClient | None
|
Optional pre-configured |
None
|
Example
async with HeartbeatClient(
base_url="https://agent-ops.internal",
headers={"Authorization": f"Bearer {iam_token}"},
) as hb:
created = await hb.create_config(
agent_id, instance_id,
cron_expression="0 * * * *",
agent_image="us-docker.pkg.dev/.../joke",
agent_image_tag="1.1.10",
task_content="Check in on any pending tasks.",
budget=HeartbeatBudget(max_daily_runs=24, max_daily_tokens=100_000, max_daily_cost_usd=5),
)
create_config(agent_id: str, instance_id: str, *, cron_expression: str, agent_image: str, agent_image_tag: str, task_content: str, budget: HeartbeatBudget | dict[str, Any], timezone: str = 'UTC') -> HeartbeatConfigCreated
async
Create a heartbeat config — schedules agentTaskWorkflow on cron_expression.
MVP scope (platform-side): exactly one cron trigger per instance.
A second call for the same instance gets a 409 (mapped to
:class:~novaos_client_sdk.exceptions.NovaOSServerError with
status_code=409), not a duplicate schedule.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
agent_id
|
str
|
The agent this instance belongs to. |
required |
instance_id
|
str
|
The instance to schedule heartbeats for. |
required |
cron_expression
|
str
|
Standard cron expression (e.g. |
required |
agent_image
|
str
|
OCI image ref to run on each scheduled fire. |
required |
agent_image_tag
|
str
|
Tag for |
required |
task_content
|
str
|
Message content sent to the agent on each fire. |
required |
budget
|
HeartbeatBudget | dict[str, Any]
|
Daily run/token/cost caps — required by the platform, no default is invented here (an unlimited default would be a real cost-control gap). |
required |
timezone
|
str
|
IANA timezone for the cron schedule (default "UTC"). |
'UTC'
|
Returns:
| Type | Description |
|---|---|
HeartbeatConfigCreated
|
The created config's ID and its underlying Temporal schedule ID. |
list_configs(agent_id: str, instance_id: str) -> list[HeartbeatConfig]
async
List heartbeat configs for an instance (0 or 1, per the MVP's unique-instance constraint).
pause_config(agent_id: str, instance_id: str, config_id: str) -> HeartbeatConfigStatus
async
Pause a heartbeat config's schedule(s). Idempotent — pausing an already-paused config no-ops.
resume_config(agent_id: str, instance_id: str, config_id: str) -> HeartbeatConfigStatus
async
Resume a paused heartbeat config's schedule(s). Idempotent — resuming an already-active config no-ops.
HeartbeatConfig(config_id: str = '', instance_id: str = '', status: str = '', triggers: list[dict[str, Any]] = list(), budget: HeartbeatBudget | None = None, schedules: list[HeartbeatSchedule] = list())
dataclass
A heartbeat config as returned by :meth:HeartbeatClient.list_configs.
HeartbeatConfigCreated(config_id: str = '', instance_id: str = '', status: str = '', temporal_schedule_id: str = '')
dataclass
Result of :meth:HeartbeatClient.create_config.
HeartbeatConfigStatus(config_id: str = '', instance_id: str = '', status: str = '')
dataclass
Result of :meth:HeartbeatClient.pause_config / :meth:resume_config.
HeartbeatSchedule(schedule_id: str = '', temporal_schedule_id: str = '', status: str = '', cron_expression: str | None = None, timezone: str = 'UTC', next_run_at: str | None = None, last_run_at: str | None = None)
dataclass
One Temporal-backed schedule under a :class:HeartbeatConfig.
novaos_client_sdk.mcp
MCP Gateway client for tool discovery and execution.
Provides a lightweight client for communicating with the NovaOS MCP Gateway using JSON-RPC 2.0 over HTTP. The MCP Gateway acts as a proxy between agents and MCP servers, handling session management and tool routing.
Protocol reference:
- tools/list — Discover available tools (optionally filtered by query)
- tools/call — Execute a tool by qualified name
The MCP Gateway uses Mcp-Session-Id headers for session affinity with
downstream MCP servers (5-minute TTL).
Example
from novaos_client_sdk.context import ExecutionContext
from novaos_client_sdk.mcp import MCPClient
ctx = ExecutionContext.from_headers(request.headers)
async with MCPClient(
base_url=os.environ["MCP_GATEWAY_URL"],
headers=ctx.to_auth_headers(),
) as mcp:
tools = await mcp.tools_list(query="affirmations")
result = await mcp.tools_call(
"novaos.affirmations.generate_affirmation",
arguments={"theme": "motivation"},
)
print(result.content)
MCPClient(base_url: str | None = None, headers: dict[str, str] | None = None, timeout: float | None = None)
Async client for the NovaOS MCP Gateway.
Communicates via JSON-RPC 2.0 over HTTP. Manages Mcp-Session-Id
for session affinity with downstream MCP servers.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
base_url
|
str | None
|
MCP Gateway URL. If None, reads from |
None
|
headers
|
dict[str, str] | None
|
Additional HTTP headers (typically includes |
None
|
timeout
|
float | None
|
Request timeout in seconds. |
None
|
session_id: str | None
property
The current MCP session ID, if established.
tools_call(tool_name: str, arguments: dict[str, Any] | None = None) -> MCPToolResult
async
Execute a tool via the MCP Gateway.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
tool_name
|
str
|
Fully qualified tool name
(e.g., |
required |
arguments
|
dict[str, Any] | None
|
Tool input arguments matching the tool's input schema. |
None
|
Returns:
| Type | Description |
|---|---|
MCPToolResult
|
MCPToolResult with the tool's response. |
Raises:
| Type | Description |
|---|---|
NovaOSConnectionError
|
If connection to the MCP Gateway fails. |
NovaOSServerError
|
If the gateway or tool returns an error. |
tools_list(query: str | None = None) -> list[MCPTool]
async
Discover available tools from the MCP Gateway.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
query
|
str | None
|
Optional query string to filter tools by name or description. |
None
|
Returns:
| Type | Description |
|---|---|
list[MCPTool]
|
List of available MCPTool descriptors. |
Raises:
| Type | Description |
|---|---|
NovaOSConnectionError
|
If connection to the MCP Gateway fails. |
NovaOSServerError
|
If the gateway returns an error. |
MCPTool(name: str, description: str | None = None, input_schema: dict[str, Any] | None = None)
dataclass
Descriptor for an MCP tool returned by tools/list.
Attributes:
| Name | Type | Description |
|---|---|---|
name |
str
|
Fully qualified tool name (e.g., |
description |
str | None
|
Human-readable description of what the tool does. |
input_schema |
dict[str, Any] | None
|
JSON Schema describing the tool's expected input parameters. |
from_dict(data: dict[str, Any]) -> MCPTool
classmethod
Create from a JSON-RPC result entry.
to_dict() -> dict[str, Any]
Convert to dictionary.
MCPToolResult(content: list[dict[str, Any]] = list(), is_error: bool = False, metadata: dict[str, Any] | None = None)
dataclass
Result of an MCP tools/call invocation.
Attributes:
| Name | Type | Description |
|---|---|---|
content |
list[dict[str, Any]]
|
The tool's response content (list of content blocks or a single value). |
is_error |
bool
|
Whether the tool execution resulted in an error. |
metadata |
dict[str, Any] | None
|
Additional metadata from the response. |
text: str | None
property
Extract the first text content block, if any.
from_dict(data: dict[str, Any]) -> MCPToolResult
classmethod
Create from a JSON-RPC result.
Utilities
novaos_client_sdk.logging
Structured JSON logger for NovaOS agents.
Provides a stdlib-only, zero-dependency JSON logger that emits log lines compatible with the Loki/Promtail ingestion pipeline introduced for developer log access and observability on published agents.
Each log line written to stdout has the form:
{"timestamp": "2026-07-25T14:00:00.123456Z", "level": "info",
"message": "Tool call completed",
"run_id": "run-abc...", "agent_id": "agent-xyz...", "tenant_id": "t-...",
"tool": "search"}
This makes every line filterable in the marketplace Agent Log Panel by level and run_id, and queryable in Grafana/Loki with:
{namespace="novaos-execution"} | json | run_id="<run-uuid>"
Usage via AgentExecutionContext (recommended): @agent.skill("run", description="...") async def run(ctx: AgentExecutionContext, input: str) -> SkillResult: ctx.logger.info("Starting run") ctx.logger.error("Tool failed", extra={"tool": "search", "error": str(e)})
Standalone usage (outside an A2A handler): from novaos_client_sdk import get_agent_logger logger = get_agent_logger(run_id="run-abc", agent_id="agent-xyz", tenant_id="t-1") logger.info("Processing started")
get_agent_logger(*, run_id: str = '', agent_id: str = '', tenant_id: str = '', name: str = 'novaos.agent') -> logging.Logger
Return a structured JSON logger bound to the given execution context fields.
The logger writes newline-delimited JSON to stdout. Each line includes
run_id, agent_id, and tenant_id (when non-empty) so that Loki
can correlate entries across the execution namespace.
The returned logging.Logger is a standard Python logger — all normal
logging methods (info, warning, error, debug, exception)
work as expected. Pass additional fields via extra:
logger.info("Tool result", extra={"tool": "search", "hit_count": 5})
Structured-logging-style keyword calls also work, e.g. logger.info("Tool
result", tool="search") — any keyword the method doesn't otherwise recognize
is folded into extra automatically instead of raising TypeError.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
run_id
|
str
|
Execution run ID (from |
''
|
agent_id
|
str
|
Agent ID (from |
''
|
tenant_id
|
str
|
Tenant ID (from |
''
|
name
|
str
|
Logger name (useful when multiple agents share a process). |
'novaos.agent'
|
Returns:
| Type | Description |
|---|---|
Logger
|
A |
Example::
from novaos_client_sdk import get_agent_logger
logger = get_agent_logger(
run_id="run-abc123",
agent_id="agent-xyz",
tenant_id="tenant-1",
)
logger.info("Processing started")
novaos_client_sdk.pii
PII Masking for NovaOS Agent SDK.
Detects and masks common PII patterns in strings before they are logged.
Patterns detected
- Email addresses
- Phone numbers (flexible grouping/length — see note below)
- SSN (XXX-XX-XXXX, with optional space/dot/dash separators)
- Credit card numbers (13-19 digits, flexible grouping — covers Visa/MC 16, Amex 15, Diners 14, and other network lengths)
- IP addresses (IPv4)
⚠️ Pattern-based detection is inherently best-effort, especially for phone
numbers: valid formats vary enormously by locale (grouping, separators,
country-code presence) and no fixed set of regexes can be complete. The
patterns below are deliberately broad — favoring masking something that
wasn't actually PII over missing something that was, since under-masking is
the more dangerous failure mode for a safety utility. For any field whose
name is known to carry sensitive data regardless of its format (e.g. a
field literally called "phone" or "ssn"), prefer key-aware masking — see
mask_pii_in_dict's _SENSITIVE_KEY_SUBSTRINGS — over relying on the
value happening to match one of these patterns.
Ported from NovaOs/packages/agent-sdk/python/novaos_sdk/pii.py
mask_pii(text: str | None) -> str | None
Mask PII patterns in the given text.
mask_pii_in_dict(data: dict[str, Any]) -> dict[str, Any]
Recursively mask PII in all string values of a dictionary.
Values under a key whose name matches a known-sensitive substring (see
_SENSITIVE_KEY_SUBSTRINGS) are fully redacted regardless of their
content — this is the key-aware backstop for values whose format doesn't
match any pattern in mask_pii. Every other value is masked by pattern
as before.
novaos_client_sdk.utils
Utility functions for NovaOS Client SDK.
match_capabilities(required: str, available: list[str], threshold: float = 0.8) -> tuple[str, float] | None
Fuzzy match a required capability to available capabilities.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
required
|
str
|
Required capability name |
required |
available
|
list[str]
|
List of available capability names |
required |
threshold
|
float
|
Minimum similarity score (0.0-1.0) to consider a match |
0.8
|
Returns:
| Type | Description |
|---|---|
tuple[str, float] | None
|
Tuple of (matched_capability, similarity_score) if match found above threshold, |
tuple[str, float] | None
|
None otherwise |
Examples:
normalize_capability_name(name: str) -> str
Normalize capability name for consistent matching.
Normalizes capability names by: - Converting to lowercase - Replacing underscores and spaces with hyphens - Removing extra whitespace - Removing special characters (keeping alphanumeric and hyphens)
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
name
|
str
|
Capability name (e.g., "Web Scraping", "web_scraping", "Web-Scraping") |
required |
Returns:
| Type | Description |
|---|---|
str
|
Normalized name (e.g., "web-scraping") |
Examples:
novaos_client_sdk._resilience
Shared resilience primitives for NovaOS client SDK.
Provides :class:CircuitBreaker and :func:backoff_delay so both
:class:~novaos_client_sdk.memory.MemoryClient and
:class:~novaos_client_sdk.tools.KnowledgeTool share the same battle-tested
retry/circuit-breaker machinery without duplication.
Design notes
CircuitBreakeris a pure, dependency-free implementation of the classic three-state automaton: CLOSED → OPEN → HALF_OPEN → CLOSED.backoff_delaycomputesbase * 2^(attempt-1)capped atmax_swith optional ±50 % multiplicative jitter (on by default).RETRYABLE_HTTP_STATUSESlists the HTTP status codes that indicate a transient server-side failure and are safe to retry (5xx gateway errors + 429 rate-limit). Client-side 4xx errors are not retryable.
CircuitBreaker(failure_threshold: int = 5, reset_timeout_s: float = 30.0)
Three-state circuit breaker: CLOSED → OPEN → HALF_OPEN → CLOSED.
Protects downstream services from thundering-herd after repeated failures:
- CLOSED — all requests flow normally.
- OPEN — requests are rejected immediately (fail-fast) for
reset_timeout_sseconds afterfailure_thresholdconsecutive failures. - HALF_OPEN — one probe request is allowed; success closes the breaker, failure re-opens it and resets the timer.
This class was previously duplicated as _CircuitBreaker in
memory.py. Both :class:~novaos_client_sdk.memory.MemoryClient and
:class:~novaos_client_sdk.tools.KnowledgeTool now import it from here.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
failure_threshold
|
int
|
Number of consecutive failures that trip the breaker to OPEN (default 5). |
5
|
reset_timeout_s
|
float
|
Seconds to wait in OPEN state before allowing a half-open probe (default 30.0). |
30.0
|
Example::
cb = CircuitBreaker(failure_threshold=3, reset_timeout_s=10)
if cb.allow_request():
try:
result = await do_work()
cb.record_success()
except TransientError:
cb.record_failure()
reset_timeout: float
property
Seconds before a half-open probe is allowed after the breaker opens.
state: str
property
Current state: "closed", "open", or "half_open".
allow_request() -> bool
Return True if a request should be allowed through.
record_failure() -> None
Signal that the most recent request failed. May trip the breaker.
record_success() -> None
Signal that the most recent request succeeded. Closes the breaker.
backoff_delay(attempt: int, base_s: float = 1.0, max_s: float = 16.0, *, jitter: bool = True) -> float
Return the sleep duration (seconds) for attempt (1-based).
Uses truncated binary exponential backoff::
delay = min(base_s * 2^(attempt-1), max_s)
With jitter=True (the default) the result is multiplied by a random
factor in [0.5, 1.0] so concurrent callers spread their retries.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
attempt
|
int
|
Current attempt number, 1-based. Attempt 1 → |
required |
base_s
|
float
|
Base delay in seconds (default 1.0). |
1.0
|
max_s
|
float
|
Maximum delay cap in seconds (default 16.0). |
16.0
|
jitter
|
bool
|
When |
True
|
Returns:
| Type | Description |
|---|---|
float
|
Sleep duration in seconds. |
Optional extras
These modules need an additional extra installed (pip install "novaos-agent-sdk[extra]") to actually function — the base package imports without them, but calling into these fails with a clear error naming the extra until it's installed.
crypto — execution token verification, sealed secrets, and card signing
novaos_client_sdk.token_verifier
Token verifier for validating execution tokens from the gateway.
Agents that want to verify execution tokens can use the JWKSTokenVerifier to fetch and cache public keys from the gateway's JWKS endpoint, then verify RS256-signed JWTs.
Usage
import os
from novaos_client_sdk.token_verifier import JWKSTokenVerifier
# Gateway URL is typically set as an environment variable
gateway_url = os.environ["GATEWAY_URL"]
verifier = JWKSTokenVerifier(gateway_url, issuer=os.environ["NOVAOS_TOKEN_ISSUER"])
# Verify a token from the Authorization header
token = request.headers["Authorization"].removeprefix("Bearer ")
claims = verifier.verify(token)
print(f"Verified: user_id={claims['user_id']}, agent_id={claims['agent_id']}")
Requires PyJWT[crypto]: pip install PyJWT[crypto]
JWKSTokenVerifier(gateway_url: str, audience: str = 'acp-agent', *, issuer: str, cache_ttl_seconds: int = 300, allowed_hosts: list[str] | None = None)
Verifies execution tokens using the gateway's JWKS endpoint.
Caches JWKS for a configurable duration. Agents derive the JWKS URL from the gateway URL they already know (GATEWAY_URL env var).
The verifier uses the kid (Key ID) claim in the JWT header to
match the correct key from the JWKS endpoint, supporting seamless
key rotation.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
gateway_url
|
str
|
Base URL of the execution gateway
(e.g., |
required |
audience
|
str
|
Expected |
'acp-agent'
|
issuer
|
str
|
Expected |
required |
cache_ttl_seconds
|
int
|
How long to cache the JWKS response (default: 300s). |
300
|
allowed_hosts
|
list[str] | None
|
Explicit hostname-suffix allowlist for |
None
|
jwks_url: str
property
The JWKS endpoint URL derived from the gateway URL.
verify(token: str) -> dict
Verify an execution token and return decoded claims.
Automatically fetches and caches the JWKS. Matches the kid
from the JWT header to the correct key in the JWKS.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
token
|
str
|
The JWT string (without |
required |
Returns:
| Type | Description |
|---|---|
dict
|
Dictionary of decoded JWT claims, including: |
dict
|
|
dict
|
|
dict
|
|
dict
|
|
dict
|
|
dict
|
|
dict
|
|
Raises:
| Type | Description |
|---|---|
InvalidTokenError
|
If the token is invalid, expired, or cannot be verified. |
DecodeError
|
If the token is malformed. |
novaos_client_sdk.a2a_user_context
The user-context/v1 A2A extension — sealed context.secrets delivery.
This is the extension chosen to carry a run's resolved secrets from the Temporal worker to the agent pod, and it is the first real implementation on top of the generic extension mechanism. It is deliberately a small module: the three hard problems it sits between are already solved elsewhere, and this is the join.
============================ ==============================================
:mod:.a2a_extensions declaring, negotiating and dispatching any
extension
:mod:.a2a_sealed_envelope the cipher suite and the wire format
:mod:.a2a_pod_keyring which private key opens which envelope
this module "the payload at metadata[user-context/v1]
is a map of sealed secrets, and here is how a
skill gets one out"
============================ ==============================================
Adding it changed nothing in the core. The extension mechanism's OCP requirement was that a
future extension be addable without editing the negotiation/dispatch code, and
this was that requirement's first real test: :class:UserContextExtension
sets uri, overrides :meth:~.a2a_extensions.A2AExtension.read_value, and is
otherwise an ordinary :class:~.a2a_extensions.A2AExtension. No line of
a2a_extensions.py moved to accommodate it.
The wire shape. One JSON object under the extension's own URI::
"metadata": {
"novaos.platform/extensions/user-context/v1": {
"secrets": {
"OPENAI_API_KEY": {"v": 1, "suite": "…", "kid": "…", "epk": "…", "iv": "…", "ct": "…"},
"SMTP_PASSWORD": {"v": 1, …}
}
}
}
The wrapper object rather than a bare secret map is what lets v1 grow a
sibling field — mcp_gateway_token is the named candidate (see
the platform's adapter layer note on it) — without becoming v2 and invalidating every
Agent Card that declared this URI. Secret names travel in the clear, by
design: a pod must route a value to ctx.get_secret("SMTP_PASSWORD") before
it can decrypt anything, and the threat model this design addresses is disclosure of credential
values.
Opening is deferred, and that is the whole design. Every envelope is bound
to one delivery — tenant, instance, run and secret name — and
:meth:~.a2a_extensions.A2AExtension.read_value is handed the payload and
nothing else: it has no idea which run it is serving. So it returns a
:class:SealedUserContext, which holds the envelopes and opens one only when a
caller supplies the delivery it believes it is serving. Deferring is not
laziness for its own sake; it is what keeps the binding check meaningful, since
a binding built from the envelope itself would check nothing at all.
Who calls what. A skill does not touch this module:
:meth:novaos_client_sdk.agent_context.AgentExecutionContext.get_secret builds
the delivery from the request it is serving and calls
:meth:SealedUserContext.open. A pod author's whole involvement is one
registration at construction::
agent = AgentBuilder(name="mailer", version="1.0.0")
agent.register_extension(UserContextExtension())
That registration is explicit on purpose (the extension mechanism deliberately has no module-level default registry) — what an agent declares on its card should be something its construction states outright, not a consequence of which modules were imported.
Requires the crypto extra::
pip install 'novaos-client-sdk[crypto]'
Design notes: this extension's choice and the SDK gap it fills are documented in the
migration's architecture decision record.
Sealing side: the platform's shared A2A library's buildUserContextExtension.
SealedUserContext(envelopes: Mapping[str, Any], keyring: PodKeyring)
The sealed payload of one request, opened one secret at a time.
Produced by :meth:UserContextExtension.read_value. Holding one is holding
ciphertext and a keyring — never plaintext: nothing is decrypted until
:meth:open is called, and nothing is cached afterwards. A skill that
needs a value twice should keep the string it was given rather than ask
again, which is the cheaper and the more careful of the two.
The instance is safe to log: :meth:secret_names is the only thing it will
render, and names travel in the clear on the wire anyway.
Build a sealed context. Normally produced by :class:UserContextExtension.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
envelopes
|
Mapping[str, Any]
|
The |
required |
keyring
|
PodKeyring
|
The private keys this pod holds. |
required |
secret_names: tuple[str, ...]
property
Every secret name this request delivered, sorted.
Safe to log, and the right thing to log when a skill cannot find the name it expected: it distinguishes "the instance declares no such secret" from "delivery is not working at all".
open(name: str, *, tenant_id: str, instance_id: str, run_id: str) -> str
Open one delivered secret.
The delivery is supplied by the caller rather than read out of the envelope, which is the point of the whole exercise: an envelope lifted from one request and replayed into another carries its original binding, so opening it against this request's identifiers fails instead of handing over a value the caller was never entitled to.
secret_name is not a parameter — it is name. Binding the value
to the key it is filed under is what stops an envelope being moved from
API_KEY to SMTP_PASSWORD inside one run, and letting a caller
pass the two separately would let that check be defeated by a typo.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
name
|
str
|
The secret's name. |
required |
tenant_id
|
str
|
Tenant this run belongs to. Pass the value the
auth-proxy sidecar vouched for where there is one — see
:class: |
required |
instance_id
|
str
|
The user-agent instance being executed. |
required |
run_id
|
str
|
The run being served. |
required |
Returns:
| Type | Description |
|---|---|
str
|
The secret value. |
Raises:
| Type | Description |
|---|---|
SecretNotDeliveredError
|
No secret arrived under that name. |
RecipientKeyUnavailableError
|
The envelope names a key this pod does not hold — usually one that rotated out of its overlap window, or was revoked. |
SealedEnvelopeFormatError
|
The envelope is malformed or names a suite this SDK does not speak. |
SealedEnvelopeError
|
The envelope did not open. Reported without saying whether the cause was the wrong key, a tampered ciphertext or a binding mismatch, since telling them apart is a decryption oracle. |
UserContextExtension(keyring: PodKeyring | None = None, *, required: bool = False)
Bases: A2AExtension
The novaos.platform/extensions/user-context/v1 extension.
Register it on an agent that needs per-run secrets::
agent = AgentBuilder(name="mailer", version="1.0.0")
agent.register_extension(UserContextExtension())
@agent.skill("send")
async def send(ctx, text):
password = ctx.get_secret("SMTP_PASSWORD")
Serving side only. The caller's half —
:meth:~.a2a_extensions.A2AExtension.request_value — is deliberately not
overridden: sealing needs the destination pod's public key, which lives in
the control plane's key store, and nothing in an agent process has it. An
agent calling another agent therefore declares this extension without being
able to populate it, and inherits the base class's "publish nothing", which
is the truthful answer rather than a stub that would seal to itself.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
keyring
|
PodKeyring | None
|
The private keys this pod holds. Defaults to a
:class: |
None
|
required
|
bool
|
Whether a caller must activate this extension to be served at
all. Defaults to |
False
|
keyring: PodKeyring
property
The keys this extension opens envelopes with.
params() -> dict[str, Any]
What the Agent Card publishes about this extension.
The suite is the compatibility contract and the only thing a caller can act on: a worker that seals under a suite the pod does not implement should discover that from the card, not from a pod-side format error on the first run of a rollout. Key ids are deliberately absent — the sealing side gets those from the control plane's key store, which is also the only place that knows when one was revoked.
read_value(value: Any) -> SealedUserContext
Interpret the payload a caller published at metadata[uri].
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
value
|
Any
|
The payload, or |
required |
Returns:
| Type | Description |
|---|---|
SealedUserContext
|
The delivered envelopes, unopened. |
Raises:
| Type | Description |
|---|---|
SealedEnvelopeFormatError
|
The payload is not the shape this version
describes. Refused rather than ignored: a caller that sent
something here and got silence would look like a caller that
sent nothing, and the run would fail later with
:class: |
novaos_client_sdk.a2a_sealed_envelope
NovaOS Sealed Envelope v1 — the opening half.
A2A carries extension data as plain JSON in the request body's metadata
map. The protocol gives that field no confidentiality at all: it is visible to
exactly the infrastructure a request header would be — the auth-proxy sidecar,
any proxy on the path, and APM tooling that records request bodies. The
extension mechanism was chosen for context.secrets delivery anyway, on
the explicit condition that NovaOS build its own encryption on top. The
Temporal worker seals each secret with the platform's shared crypto module;
this module is what opens it, and it runs inside the agent process —
past the sidecar, in the one place the plaintext is supposed to exist.
The suite. X25519 ECDH with a fresh ephemeral sender key per envelope,
HKDF-SHA256 to a 256-bit content key, AES-256-GCM to seal. The construction is
spelled out byte for byte rather than delegated to a JOSE library, because the
sealing half is TypeScript and the two must agree exactly; a fully specified
suite can be pinned by shared test vectors, and "whatever two JOSE libraries
both call ECDH-ES+A256GCM" cannot. tests/test_a2a_sealed_envelope.py opens
the same fixture packages/shared/src/crypto/testdata/sealed-envelope-vectors.json
does in the platform monorepo. Normative spec:
docs/security/sealed-envelope-v1.md in the platform monorepo.
Every envelope is bound to one delivery. The AEAD's additional data commits
to the tenant, instance, run and secret name the value was sealed for
(:class:SecretDelivery). That data is never transmitted — both sides rebuild
it from identifiers they hold independently, which is exactly what makes the
check worth anything: an envelope lifted from one request and replayed into
another fails to open rather than quietly delivering a value the caller was
never entitled to. On a fleet pod, where one process serves many tenants, that
is the difference between an encryption layer and a real isolation boundary.
What this module is not. It does not know where an envelope came from —
reading metadata and picking the extension URI is a caller's job — and it
does not manage keys; :mod:novaos_client_sdk.a2a_pod_keyring holds those.
Requires the crypto extra::
pip install 'novaos-client-sdk[crypto]'
SecretDelivery(tenant_id: str, instance_id: str, run_id: str, secret_name: str)
dataclass
The one delivery an envelope is valid for.
Every field is an identifier the pod already has from the request it is serving, and the worker had from the run it dispatched. None of them is secret; binding to them does not hide them, it makes the ciphertext refuse to open anywhere else.
Prefer values the auth-proxy sidecar vouched for over values read out of
the request body where both are available — tenant_id in particular
arrives as the X-NovaOS-Tenant-Id header the sidecar injects, and the
sidecar strips any the caller tried to set. A binding built from the body
alone still defeats replay between runs, but a binding built from the header
also defeats a caller who lies about who they are.
Attributes:
| Name | Type | Description |
|---|---|---|
tenant_id |
str
|
Tenant the run belongs to. |
instance_id |
str
|
User-agent instance being executed. |
run_id |
str
|
The run this secret was sealed for. |
secret_name |
str
|
Name the value is delivered under, so an envelope cannot be moved from one secret to another inside the same run. |
b64u_decode(value: Any, field: str, *, expected_bytes: int | None = None) -> bytes
Decode unpadded base64url strictly.
base64.urlsafe_b64decode is permissive by default — it will happily
accept the standard alphabet's + and /, and Python's decoder ignores
some invalid input rather than failing. Re-encoding and comparing is the
cheapest way to get the strict parser a wire format needs, and it keeps this
decoder's acceptance identical to the TypeScript half's.
Typed Any rather than str on purpose: every caller is parsing
untrusted JSON, where a member can be absent or of any type, and the check
belongs in one place rather than at each call site.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
value
|
Any
|
The encoded string, or whatever arrived where one was expected. |
required |
field
|
str
|
Field name, used only in the error message. |
required |
expected_bytes
|
int | None
|
When given, the exact decoded length required. |
None
|
Raises:
| Type | Description |
|---|---|
SealedEnvelopeFormatError
|
If |
b64u_encode(data: bytes) -> str
Encode data as unpadded base64url — the only encoding this format uses.
envelope_key_id(envelope: Any) -> str
Return the kid an envelope names, without opening it.
A keyring needs this to pick which private key to try, before it has anything to decrypt with. Validates only enough to answer the question.
Raises:
| Type | Description |
|---|---|
SealedEnvelopeFormatError
|
If |
open_envelope(envelope: Any, private_key: X25519PrivateKey, recipient_public: bytes, kid: str, delivery: SecretDelivery) -> str
Open one sealed envelope.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
envelope
|
Any
|
The envelope as it came off the wire — a plain |
required |
private_key
|
X25519PrivateKey
|
The recipient private key, held only by this pod. |
required |
recipient_public
|
bytes
|
The 32 raw bytes of |
required |
kid
|
str
|
The key id |
required |
delivery
|
SecretDelivery
|
The delivery this pod believes it is serving. |
required |
Returns:
| Type | Description |
|---|---|
str
|
The secret value, decoded as UTF-8. |
Raises:
| Type | Description |
|---|---|
SealedEnvelopeFormatError
|
The envelope is malformed, or names a version or suite this implementation does not speak. |
SealedEnvelopeError
|
The envelope names a different key, or failed to open. A failure to open is reported without saying whether the cause was the wrong key, a tampered ciphertext or a binding mismatch — distinguishing them would be a decryption oracle. |
novaos_client_sdk.a2a_pod_keyring
The private keys a pod opens sealed envelopes with.
:mod:novaos_client_sdk.a2a_sealed_envelope knows how to open an envelope given
a key. This module is where the key comes from, and it is the whole of the
pod's side of NovaOS's key management: it loads recipient private keys from the
volume the control plane mounts, picks the right one by kid, and notices
when that volume changes underneath it.
Where the keys come from. The control plane mints an X25519 key pair per
scope — one agent pod, or one fleet agent — writes the private half into a
Kubernetes Secret, and keeps only the public half to seal with. The Secret is
mounted as a directory into the agent container and into nothing else, one file
per key, named {kid}.jwk. Nothing here reaches out over the network: a key
that is not on the filesystem is a key this pod does not have.
Why a directory and not an environment variable. Environment variables are
read once at process start, so rotating a key would mean restarting the pod —
which for a long-lived fleet Deployment is a rolling restart of the thing
currently serving traffic, an operational cost high enough that rotation would
quietly stop happening. The kubelet updates a mounted Secret in place, so
:class:PodKeyring picks up a new key by re-reading the directory. Two smaller
reasons point the same way: a mounted file is not inherited by child processes
the way the environment is, and it does not show up in kubectl describe pod,
in crash dumps, or in agent-ops' env-visibility endpoint.
Rotation, seen from the pod. During a rotation the directory holds two
keys: the one the worker is now sealing to, and its predecessor, kept until
requests sealed just before the change have all landed. The pod needs no
notion of which is which — it opens what it is given, by kid. A key
disappearing from the directory is how both the end of an overlap window and
a revocation reach this process; from here they look the same, and both mean
the same thing: envelopes naming that key can no longer be opened.
A stale view is re-read before a cached key is trusted, not only after a
miss. A kid this pod has not seen is the normal first symptom of a
rotation, so a miss re-reads the directory and tries again — but a hit is
re-checked too, because otherwise a key that has been revoked keeps opening
envelopes for the life of the process and revocation never reaches a pod that
had already loaded it successfully. That mattered here more than anywhere: a
fleet Deployment is long-lived by definition and is the only pod model that
reads this keyring at all.
Neither path re-reads more often than :data:MIN_RELOAD_INTERVAL_SECONDS. The
throttle is what makes checking a hit affordable — at most one directory read
per interval however many envelopes arrive — and it is also a defence: an
unknown kid is what a stream of forged envelopes looks like, and an
unthrottled reload would turn that into filesystem load an attacker controls.
A revocation therefore takes effect within that interval plus the kubelet's own
Secret refresh, rather than at the next pod restart.
Requires the crypto extra::
pip install 'novaos-client-sdk[crypto]'
PodKeyring(directory: str | os.PathLike[str] | None = None, *, now: Any = time.monotonic)
The recipient keys available inside this agent process.
Construct it once per process. It is cheap to hold and its
:meth:open is the only entry point the rest of the SDK needs;
the user-context/v1 extension is the intended caller.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
directory
|
str | PathLike[str] | None
|
Where key files live. Defaults to the
:data: |
None
|
now
|
Any
|
Injectable clock, used only for the reload throttle. Tests drive it; production omits it. |
monotonic
|
Example
keyring = PodKeyring() # doctest: +SKIP secret = keyring.open( # doctest: +SKIP ... envelope, ... SecretDelivery( ... tenant_id=ctx.tenant_id, ... instance_id=ctx.instance_id, ... run_id=ctx.run_id, ... secret_name="OPENAI_API_KEY", ... ), ... )
key_ids: list[str]
property
The ids of every key currently loaded, for diagnostics and health checks.
Safe to log and to expose: a kid is a hash of the public half.
Being able to see which keys a pod holds is most of what makes a
rotation or a suspected compromise investigable.
from_keys(keys: list[RecipientKey]) -> PodKeyring
classmethod
Build a keyring over keys already in memory.
For tests and for embedding hosts that hold key material themselves.
The caller owns the keys outright: this keyring has no directory at all,
so a cache miss is final rather than a prompt to reload, and
:meth:reload is a no-op instead of something that would replace the
caller's keys with whatever is (not) on disk.
open(envelope: Any, delivery: SecretDelivery) -> str
Open one sealed envelope with whichever key it names.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
envelope
|
Any
|
The envelope as it came off the wire. |
required |
delivery
|
SecretDelivery
|
The delivery this pod believes it is serving — see
:class: |
required |
Returns:
| Type | Description |
|---|---|
str
|
The secret value. |
Raises:
| Type | Description |
|---|---|
RecipientKeyUnavailableError
|
This pod holds no key with the
envelope's |
SealedEnvelopeFormatError
|
The envelope is malformed or names a suite this SDK does not speak. |
SealedEnvelopeError
|
The envelope did not open. |
reload() -> None
Re-read the key directory now, ignoring the throttle.
Worth calling from a pod's readiness path, where the cost of a filesystem read is irrelevant and starting to serve with a stale view of the mounted Secret is not.
A no-op on a keyring built by :meth:from_keys, which has no directory
to re-read — the readiness-path advice above must not cost an embedding
host the keys it handed over.
RecipientKey(kid: str, private_key: X25519PrivateKey, public_bytes: bytes)
dataclass
One private key this pod holds, ready to open envelopes sealed to it.
public_bytes is kept alongside the private key because the sealed
envelope's key schedule mixes the recipient public key into its HKDF
context: deriving it once at load time saves doing it per secret, per run.
The instance is deliberately opaque — it has no __str__ worth printing
and the underlying :class:X25519PrivateKey does not render its scalar — so
an accidental log line carries a key id, never key material.
jwk_thumbprint(x: str) -> str
Return the RFC 7638 thumbprint of an X25519 public key.
RFC 8037 §2 fixes an OKP key's thumbprint members at crv, kty and
x; RFC 7638 fixes their order and requires compact JSON with no
whitespace. The canonical form is written out literally rather than handed
to :func:json.dumps, because the TypeScript half computes the same value
from the same rule and a serialiser's key ordering is not part of either
language's contract.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
x
|
str
|
The public key, unpadded base64url — a JWK's |
required |
novaos_client_sdk.a2a_card_signing
JWS signature verification for A2A Agent Cards.
AgentCard.signatures (:mod:novaos_client_sdk.a2a_agent_card) is parsed and
re-emitted byte-for-byte but was never checked — defensible while every card
this SDK consumed came from NovaOS's own gateway over a trusted path, and
indefensible the moment :class:~novaos_client_sdk.discovery.AgentDiscoveryService
fetches a third-party partner agent's card, which its own docstring says it
supports. This module is the check: it turns a well-formed card into a
trustworthy one.
Reference implementation. a2a-sdk's a2a.utils.signing module is the
interoperability target — :func:canonicalize_agent_card reproduces its
_canonicalize_agent_card byte-for-byte (verified against the real signer in
tests/test_a2a_card_signing.py), and :func:verify_agent_card_signatures
mirrors its create_signature_verifier: at least one valid signature is
enough, and an unsigned card and a signed-but-invalid one raise distinct errors
(:class:~novaos_client_sdk.exceptions.NoAgentCardSignatureError and
:class:~novaos_client_sdk.exceptions.InvalidAgentCardSignatureError).
One deliberate divergence. The reference's create_signature_verifier
takes a caller-supplied key_provider(kid, jku), so it can fetch keys from a
jku URL named inside the card's own protected header. This module never
does that — trusting a URL the document being verified points to would make
the check circular. It only ever fetches keys from the jwks_url the caller
passed in explicitly, which is why that is the one required argument.
Canonicalization is where this is won or lost. Verification only means
anything if the bytes this module hashes are the exact bytes the signer hashed.
:func:canonicalize_agent_card uses rfc8785 (an RFC 8785 JSON
Canonicalization Scheme implementation) rather than hand-rolling one, with the
same signatures-removal and empty-value-stripping the reference applies
before canonicalizing.
Canonicalize the fetched JSON, not a re-serialized dataclass.
:func:canonicalize_agent_card and :func:verify_agent_card_signatures both
accept either an :class:AgentCard or a raw Mapping (a parsed
response.json() body). Passing the raw mapping is not a convenience
overload — it is the only sound choice for a fetched card.
:meth:AgentCard.from_dict is deliberately tolerant, which means it is also
lossy: a field this SDK does not yet model (a vendor extension, a later spec
revision, anything from_dict does not read into a named attribute) does
not survive AgentCard.from_dict(payload).to_dict(). Canonicalizing that
round-tripped dict instead of the original payload would hash a different
document than whatever the signer actually signed, and a caller that verifies
the wrong bytes gets no guarantee at all — a genuinely-signed card carrying an
unmodeled field would fail verification, while a still-unmodeled field an
attacker tampered with would never be examined either way.
:class:~novaos_client_sdk.discovery.AgentDiscoveryService verifies the raw
response.json() payload for exactly this reason; the AgentCard overload
exists for a card built or held in memory with no surviving wire bytes (tests,
mainly).
Requires the crypto extra::
pip install 'novaos-client-sdk[crypto]'
canonicalize_agent_card(card: AgentCard | Mapping[str, Any]) -> bytes
Return the RFC 8785 canonical JSON bytes a card's signature is computed over.
Drops signatures (a signature cannot cover itself) and recursively
strips empty values the same way the reference implementation does, then
canonicalizes what remains.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
card
|
AgentCard | Mapping[str, Any]
|
The card to canonicalize — either a parsed :class: |
required |
Returns:
| Type | Description |
|---|---|
bytes
|
Canonical JSON as bytes, ready to base64url-encode into a JWS payload. |
verify_agent_card_signatures(card: AgentCard | Mapping[str, Any], *, jwks_url: str, algorithms: Sequence[str] | None = None, timeout: float | None = None, allowed_hosts: list[str] | None = None) -> None
Verify at least one of card's signatures against a JWKS endpoint.
Succeeds silently the moment one signature verifies, matching the reference implementation's policy — an agent that rotates keys can carry both an old and a new signature during the overlap window without this treating the old one's presence as a failure.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
card
|
AgentCard | Mapping[str, Any]
|
The card to verify — either a parsed :class: |
required |
jwks_url
|
str
|
JWKS endpoint to fetch verification keys from. This is
caller-supplied trusted configuration, the same trust class as
|
required |
algorithms
|
Sequence[str] | None
|
Acceptable JWS algorithms. Defaults to
:data: |
None
|
timeout
|
float | None
|
JWKS HTTP fetch timeout in seconds. |
None
|
allowed_hosts
|
list[str] | None
|
Explicit hostname-suffix allowlist for |
None
|
Raises:
| Type | Description |
|---|---|
NoAgentCardSignatureError
|
|
InvalidAgentCardSignatureError
|
|
llm — direct LLM Gateway access
novaos_client_sdk.llm
LLM Gateway client for NovaOS agents.
Wraps the standard openai.AsyncOpenAI client, configured to route
through the NovaOS LLM Gateway using execution tokens for authentication.
Same pattern as MCPClient for MCP Gateway access.
The LLM Gateway speaks the OpenAI API protocol — agents use the standard OpenAI SDK interface (chat.completions, embeddings, models) without knowing about provider routing, fallbacks, or budget enforcement happening behind the gateway.
Example
from novaos_client_sdk.context import ExecutionContext
from novaos_client_sdk.llm import LLMClient
ctx = ExecutionContext.from_headers(request.headers)
async with LLMClient(execution_context=ctx) as llm:
# Standard OpenAI SDK — streaming chat completion
stream = await llm.chat.completions.create(
model="gpt-4o",
messages=[{"role": "user", "content": "Hello"}],
stream=True,
)
async for chunk in stream:
print(chunk.choices[0].delta.content, end="")
# Embeddings
result = await llm.embeddings.create(
model="text-embedding-3-small",
input="Some text to embed",
)
# List available models (scope-filtered by gateway)
models = await llm.available_models()
LLMClient(base_url: str | None = None, execution_context: ExecutionContext | None = None, timeout: float = 600.0)
Async client for the NovaOS LLM Gateway.
Wraps openai.AsyncOpenAI with:
- base_url pointing to the LLM Gateway (not OpenAI directly)
- api_key set to the execution token (validated by gateway's custom_auth)
- X-NovaOS-* default headers for observability correlation
The returned object exposes the full openai.AsyncOpenAI interface:
chat.completions, embeddings, models, etc.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
base_url
|
str | None
|
LLM Gateway URL. If None, reads from |
None
|
execution_context
|
ExecutionContext | None
|
ExecutionContext with the execution token for auth. If None, the client will not send auth headers (useful for local dev with auth disabled on the gateway). |
None
|
timeout
|
float
|
Request timeout in seconds (default: 600, matches OpenAI SDK default). |
600.0
|
chat: AsyncChat
property
Access chat completions (client.chat.completions.create(...)).
embeddings: AsyncEmbeddings
property
Access embeddings (client.embeddings.create(...)).
models: AsyncModels
property
Access model listing (client.models.list()).
available_models() -> list[str]
async
Returns model names the caller has access to.
Calls /v1/models on the LLM Gateway, which filters the response
based on the caller's llm:model:* scopes in the execution token.
Only models the caller is authorized to use are returned.
Returns:
| Type | Description |
|---|---|
list[str]
|
List of model name strings (e.g., ["gpt-4o", "gpt-4o-mini"]). |
close() -> None
async
Close the underlying HTTP client.
has_model(model: str) -> bool
async
Check if a specific model is available to the caller.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
model
|
str
|
Model name to check (e.g., "gpt-4o"). |
required |
Returns:
| Type | Description |
|---|---|
bool
|
True if the caller has scope-based access to the model. |
schema — structured-output validation
novaos_client_sdk.schema_validation
Structured-extraction validation against a caller-supplied JSON Schema (Ex10).
Pairs with :attr:novaos_client_sdk.agent_context.AgentExecutionContext.output_schema:
a skill that performs structured extraction (e.g. via an LLM call or BAML)
can check its own result against the caller's schema before returning,
instead of trusting the extraction blindly.
This module does not perform extraction itself — that remains the skill's own business logic (whatever LLM call or parsing approach it already uses). It only validates structural correctness of an already-produced result.
Requires the optional jsonschema>=4.18.0 dependency (pip install
novaos-agent-sdk[schema]) — 4.18.0 specifically, not just any
jsonschema, because this module relies on the referencing-based
Registry/registry= API to block remote $ref resolution (see
below); that API doesn't exist in jsonschema 4.0–4.17. The import is
deferred to :func:validate_against_schema itself so importing this
module — or the rest of the SDK — never requires jsonschema to be
installed; only calling the function does.
.. admonition:: The schema itself is untrusted input — never resolved remotely
ctx.output_schema comes straight from the caller's request body
(Ex10). Older jsonschema releases (pre referencing migration)
fetch remote $ref targets over the network by default — a caller
could embed a $ref pointing at an internal service or a cloud
metadata endpoint (e.g. http://169.254.169.254/...) and turn
validation into an SSRF primitive. This module always validates against
an explicit registry that refuses to resolve any external reference
(network or filesystem) — a $ref that would require fetching fails
validation with a clear error instead of ever being retrieved.
validate_against_schema(data: Any, schema: dict[str, Any]) -> list[str]
Validate data against a caller-supplied JSON Schema.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
Any
|
The value to validate — typically a skill's extracted output. |
required |
schema
|
dict[str, Any]
|
A JSON Schema document, e.g. from
:attr: |
required |
Returns:
| Type | Description |
|---|---|
list[str]
|
A list of human-readable validation error messages, in the order |
list[str]
|
|
list[str]
|
A malformed |
list[str]
|
keyword, or a |
list[str]
|
produces an error message here rather than raising — the schema is |
list[str]
|
caller-supplied, untrusted input, so a skill following the |
list[str]
|
documented |
Raises:
| Type | Description |
|---|---|
ImportError
|
If the optional |
otel — exporting traces
Tracing helpers (get_traceparent, inject_trace_headers, traced_tool_call, configure_otel, ...) work without this extra as no-ops; install it to actually export spans to an OpenTelemetry collector.
novaos_client_sdk.tracing
OpenTelemetry instrumentation for NovaOS Agent Client SDK.
ERD: SDK-05 (FR-26)
Provides distributed tracing for all tool calls and gateway operations. Propagates W3C Trace Context (traceparent) on all outbound HTTP calls.
Quick-start (env-var driven)::
OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318 \
OTEL_SERVICE_NAME=my-agent \
python agent.py
Or programmatically::
from novaos_client_sdk.tracing import configure_otel
configure_otel(
endpoint="http://otel-collector:4318",
service_name="my-agent",
)
configure_otel(endpoint: str | None = None, service_name: str | None = None, headers: dict[str, str] | None = None) -> bool
Bootstrap an OTLP HTTP exporter and register it as the global TracerProvider.
Call once at agent startup. Reads from environment variables when keyword arguments are omitted:
OTEL_EXPORTER_OTLP_ENDPOINT— collector HTTP endpoint (e.g.http://otel-collector:4318)OTEL_SERVICE_NAME— service name emitted on every spanOTEL_EXPORTER_OTLP_HEADERS— comma-separatedkey=valuepairs forwarded to the collector (e.g. for auth tokens)
Returns True when the provider was successfully registered, False
when the otel extra dependencies are not installed (no-op, not an
error).
Example::
from novaos_client_sdk.tracing import configure_otel
configure_otel(
endpoint="http://otel-collector:4318",
service_name="echo-agent",
)
extract_parent_id(traceparent: str) -> str | None
Extract the 16-hex parent-id (span-id) from a traceparent string.
extract_trace_id(traceparent: str) -> str | None
Extract the 32-hex trace-id from a traceparent string.
get_traceparent() -> str
Return the current traceparent from env or active OTel context.
inject_trace_headers(headers: dict[str, str]) -> dict[str, str]
Inject W3C Trace Context headers into outbound HTTP request headers.
is_valid_traceparent(value: str) -> bool
Validate a W3C traceparent header value.
propagate_trace(headers: dict[str, str], traceparent: str = '') -> dict[str, str]
Return a copy of headers with traceparent added (if available).
traced_gateway_call(method: str, endpoint: str) -> Generator[Any | None, None, None]
Context manager that creates an OTel span for a gateway HTTP call.
traced_tool_call(tool_name: str, args: dict[str, Any] | None = None, run_id: str = '') -> Generator[Any | None, None, None]
Context manager that creates an OTel span for a tool call.
Usage
with traced_tool_call("novaos.knowledge.query", {"query": "..."}) as span: result = await self._call_tool("novaos.knowledge.query", args)
evaluation — offline/CI agent evaluation
novaos_client_sdk.evaluation
NovaOS Agent Evaluation Framework.
Provides runtime evaluation, grading, experiment management, CI/CD gates, statistical tools, and human calibration for NovaOS agents.
Install with: pip install novaos-agent-sdk[evaluation]
Public symbols are lazily imported on first access to avoid pulling in the
entire evaluation dependency graph when only evaluation.context is needed
(e.g. from AgentBuilder).
migrate — migrating a 1.x agent to A2A
A CLI, not an importable API — see the migrate CLI reference for analyze/transform/verify. Requires pip install "novaos-agent-sdk[migrate]".