Skip to content

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:

  1. An explicit CancelTask — the one and only interruption that is a decision about the task. _dispatch_cancel_task writes TASK_STATE_CANCELED first, then preempts the running skill. The dispatchers below never overwrite that write, however the skill's coroutine ends afterward.
  2. The skill's own coroutine ends in an asyncio.CancelledError no CancelTask asked 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, and SendMessage answers with the task's current, non-terminal snapshot. This is A2A's "stream ends with no terminal event" shape, which a scaffold-based a2a-sdk executor produces by simply returning without publishing one. A prior version of this module labelled it CANCELED and 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.
  3. 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 SubscribeToTask precisely 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's Task runs on, and a finisher task publishes its eventual outcome — COMPLETED/FAILED/whatever the SkillResult maps to, or nothing under rule 2, or nothing under rule 1 if a CancelTask reaches it meanwhile — through the same TaskUpdater/EventQueue the request would have, then closes the queue and unregisters both. A GetTask in between reports WORKING; a SubscribeToTask attaches to the still-registered queue and sees the terminal frame when it comes. CancelTask remains the way to actually stop a detached skill — and so does re-sending on the same task id: a SendMessage / SendStreamingMessage naming 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 its TaskUpdater — so a caller that disconnected and retried never ends up with two skills writing to one task. A prior version cancelled the skill and wrote CANCELED here 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 SendMessage-created tasks are persisted between the moment a skill starts and the moment something (GetTask/CancelTask/ListTasks) looks one up again. Defaults to a fresh :class:~novaos_client_sdk.a2a_task_store.InMemoryTaskStore, which is sufficient for a pod whose lifetime is one execution — see that class's docstring for its eviction policy. Injectable so a fleet-mode pod handling many runs, or a test, can supply its own instance instead.

None
push_notification_store PushNotificationConfigStore | None

Where this agent's push notification registrations are persisted — the Create / Get / List / Delete TaskPushNotificationConfig quartet's counterpart to task_store. Defaults to None, and that default is load-bearing rather than a mere placeholder: a pod that does not pass a store does not support push notification configuration at all — every one of the four methods answers A2A's own -32003 (:class:~novaos_client_sdk.exceptions.PushNotificationNotSupportedError) rather than a mix of "some of the four work". See :attr:supports_push_notifications, which is what a card-generating caller reads to set capabilities.pushNotifications — this class does not generate or serve an Agent Card itself, only exposes the fact a card generator needs. Pass :class:~novaos_client_sdk.a2a_push_notification_store.InMemoryPushNotificationConfigStore (or your own implementation) to opt in.

None
mask_output_pii bool

When True, run the same PII masking used by the structured logger (novaos_client_sdk.pii.mask_pii / mask_pii_in_dict) over the outbound A2A task — SkillResult.output, .metadata, and .error — before it's serialized onto the wire. Defaults to False because masking is not safe to apply unconditionally: an agent whose job is to echo back or confirm caller-supplied data (e.g. "I'll send the confirmation to 555-123-4567") would have that legitimate output redacted too. Enable this only for agents whose output should never legitimately contain PII patterns. PII masking has historically only covered structured logging — this flag is the SDK-provided way to extend the same protection to agent output, rather than each agent hand-rolling its own masking before returning SkillResult.

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:register_extension for the incremental form and :meth:declared_extensions for what gets published.

None
default_output_modes Iterable[str] | None

The media types this agent produces when a skill declares no output_modes of its own. The single source of truth for both :meth:to_agent_card's published defaultOutputModes and dispatch-time content negotiation (:meth:_supported_output_modes) — set it here, once, rather than passing it again to every to_agent_card() call, so the two cannot silently disagree about what a given skill accepts. Defaults to :data:_DEFAULT_OUTPUT_MODES (text/plain, application/json) when neither this nor a skill's own output_modes says otherwise.

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 (request) -> Response callable.

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 bytes (e.g. from an ASGI await request.body()) or str.

required
headers Any

Request headers; any mapping with .items()

required

Returns:

Type Description
dict[str, Any]

A JSON-RPC 2.0 response object, ready to serialize. PARSE_ERROR

dict[str, Any]

with id: null if raw was not valid JSON (or exceeded the

dict[str, Any]

size limit); otherwise identical to what

dict[str, Any]

meth:handle_a2a_request returns for the decoded body.

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 bytes (e.g. from an ASGI await request.body()) or str.

required
headers Any

Request headers; any mapping with .items()

required

Returns:

Type Description
dict[str, Any]

(response_body, response_headers). On a decode failure,

dict[str, str]

response_headers is always {} — no extension negotiation

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 error response — 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 result holding a task in TASK_STATE_REJECTED (unroutable) or TASK_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 .items()

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 .items()

required

Returns:

Type Description
dict[str, Any]

(response_body, response_headers). The headers are empty unless

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 neither SendStreamingMessage nor SubscribeToTask, or a SendStreamingMessage request 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_headers would 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, for SendStreamingMessage) or a subscription is being served (for SubscribeToTask). frame_iterator yields already SSE-encoded bytes (:func:novaos_client_sdk._a2a.sse_data_line), ready to hand an ASGI StreamingResponse (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 .items()

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 (AgentSkill.name on the card built by :meth:to_agent_card).

required
description str

Human-readable description of what the skill does.

''
id str | None

The routing selector a caller sends as :data:SKILL_ID_METADATA_KEY to pick this skill, and the value :meth:to_agent_card publishes as AgentSkill.id. Defaults to name, so every existing @agent.skill("name", "description") call keeps working unchanged. This agent's skills are keyed by whatever value ends up here — the same dict :func:_resolve_skill looks up at request time — so a card built by :meth:to_agent_card can never advertise an id this agent would reject.

None
tags Iterable[str] | None

Free-form category tags, for AgentSkill.tags.

None
input_modes Iterable[str] | None

Media types this skill accepts, for AgentSkill.inputModes.

None
output_modes Iterable[str] | None

Media types this skill produces, for AgentSkill.outputModes.

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 description. Unlike name and version, the builder has no attribute of its own to source this from, so it is a parameter.

''
supported_interfaces Iterable[AgentInterface] | None

Where the agent answers (AgentCard.supportedInterfaces). The builder cannot know this: pods are not internet-reachable, so the reachable URL is always the gateway's, never the pod's own.

None
streaming bool

Whether capabilities.streaming should be published. AgentBuilder always implements SendStreamingMessage/SubscribeToTask in :meth:handle_a2a_streaming_request, but that method has to actually be wired up in front of the pod for a caller to reach it — a fact only whoever is assembling this card (the execution gateway, or the Galaxy scaffold) knows, since it depends on how they route requests to this instance, not on anything AgentBuilder itself holds. Defaults to True, the common case where both entry points are wired; pass False only when the caller deliberately exposes handle_a2a_request_with_headers alone, so the card does not promise an operation that entry point declines with -32004.

True
provider AgentProvider | None

Who publishes the agent (AgentCard.provider).

None
documentation_url str | None

AgentCard.documentationUrl.

None
icon_url str | None

AgentCard.iconUrl.

None
security_schemes dict[str, Any] | None

AgentCard.securitySchemes, carried through opaque (see :mod:novaos_client_sdk.a2a_agent_card).

None
security_requirements list[dict[str, Any]] | None

AgentCard.securityRequirements.

None
default_input_modes Iterable[str] | None

The card-level default input media types (AgentCard.defaultInputModes), distinct from any given skill's own input_modes.

None
default_output_modes Iterable[str] | None

The card-level default output media types (AgentCard.defaultOutputModes), distinct from any given skill's own output_modes. Defaults to :attr:_default_output_modes — the value passed to :meth:AgentBuilder.__init__ — when omitted, which is what keeps this the same list :meth:_supported_output_modes enforces at dispatch time for the common, no-override call. An explicit value here is a one-off override for this card only; see :meth:_supported_output_modes's own docstring for why that is the one case the two can drift.

None

Returns:

Type Description
AgentCard

A populated :class:AgentCard. capabilities.streaming is the

AgentCard

streaming argument above, passed through as-is.

AgentCard

capabilities.push_notifications mirrors

AgentCard

attr:supports_push_notifications — True exactly when this

AgentCard

instance was constructed with a push_notification_store, so

AgentCard

it can never drift from what

AgentCard

handle_a2a_request_with_headers's _dispatch_push_notification_config

AgentCard

actually serves. Per that property's own docstring,

AgentCard

True here means only that

AgentCard

Create/Get/List/Delete work at this layer — it

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; () when none

...

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 logging.Logger writing structured JSON to stdout.

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 Message

required
params dict[str, Any]

The SendMessage params object, for its metadata map

required
headers dict[str, str]

Lower-cased HTTP request headers

required
task_id str

The A2A Task.id this execution belongs to

''
extensions ExtensionActivation | None

The outcome of this request's A2A-Extensions negotiation, which AgentBuilder performs before dispatch — the same division of labour as task_id: negotiating is the handler's business, and the context only carries the result so a skill can read it. Defaults to nothing activated.

None
emit_tool_event Callable[[str, dict[str, Any]], None] | None

Forwarded to :meth:__init__ — see its docstring. Only AgentBuilder's streaming handler passes one.

None
task_updater TaskUpdater | None

Forwarded to :meth:__init__ — see :attr:task_updater. Only AgentBuilder's SendMessage handler passes one.

None
cancellation _CancellationToken | None

Forwarded to :meth:__init__ — see :attr:is_cancelled / :meth:raise_if_cancelled. AgentBuilder passes the same instance it also registers for CancelTask to find.

None
referenced_tasks tuple[Task, ...]

Forwarded to :meth:__init__ — see :attr:referenced_tasks. AgentBuilder resolves message.reference_task_ids against its own task_store before calling this method — the same division of labour as task_id and extensions above: resolving is dispatch's business (it needs store access this context has no reason to hold), and the context only carries the result so a skill can read it.

()
unresolved_reference_task_ids tuple[str, ...]

Forwarded to :meth:__init__ — see :attr:unresolved_reference_task_ids.

()

Returns:

Type Description
AgentExecutionContext

A populated AgentExecutionContext.

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:~novaos_client_sdk.exceptions.SecretNotDeliveredError instead, which is the right choice for a secret the skill cannot run without.

_NO_SECRET_DEFAULT

Returns:

Type Description
Any

The secret value, or default when nothing arrived under name.

Raises:

Type Description
SecretNotDeliveredError

Nothing arrived under name and no default was given. Also raised when the caller never activated the extension at all — an agent-to-agent call or a hand-written request, neither of which seals secrets.

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 ExecutionContext populated from this context's AgentRuntimeContext.

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-Id
  • X-NovaOS-Tenant-Id
  • X-NovaOS-Run-Id
  • X-NovaOS-Agent-Id
  • X-NovaOS-Instance-Id
  • X-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 user_id claim).

tenant_id str | None

The tenant/organization ID (from token tenant_id claim).

run_id str | None

The current execution run ID (from token run_id claim).

agent_id str | None

The target agent ID (from token agent_id claim).

instance_id str | None

The agent instance/pod ID (from token instance_id claim).

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.

Example
ctx = ExecutionContext.from_headers(request.headers)
ns = ctx.memory
# ns.context is pre-fetched context (set by Agent Runtime, or None)
# For client operations, use MemoryClient directly

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 X-NovaOS-* headers.

Example
# FastAPI / Starlette
@app.post("/runs")
async def handle_run(request: Request):
    ctx = ExecutionContext.from_headers(request.headers)
    print(f"User: {ctx.user_id}, Run: {ctx.run_id}")

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 execution_token is None or empty.

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 Authorization: Bearer <token> if a token is available,

dict[str, str]

otherwise an empty dict.

Example
ctx = ExecutionContext.from_headers(request.headers)
headers = ctx.to_auth_headers()
# Pass headers to SDK client or httpx request
async with discover_and_connect(
    agent="other-agent",
    dns_base_url=dns_url,
    headers=headers,
) as client:
    task = await client.send_message(text)

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 X-NovaOS-* headers with non-None values.

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 — "user" and "assistant" in practice, which is also what OpenAI-style chat APIs expect. Passed through verbatim rather than normalised, so an unrecognised role stays visible instead of being silently rewritten to something plausible.

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 None.

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:KnowledgeTool.poll_batch_until_complete for completion.

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. collection_id, filename, source_url).

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:KnowledgeTool.ingest_batch call.

job_id str | None

Backend job UUID, or None when submission itself failed.

status str

One of "pending", "running", "completed", "failed", "dead_letter", "cancelled", "submit_error", or "unknown".

error str | None

Human-readable error message when status is "failed", "dead_letter", or "submit_error"; None otherwise.

duplicate_of str | None

Id of the existing document when the upload was a duplicate of content already indexed (the job itself finishes as "completed"); None otherwise.

is_duplicate bool

True when :attr:duplicate_of is set, or when the service reports the status "duplicate".

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 Authorization header.

required
tenant_id str

Tenant UUID for the X-Tenant-ID header.

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 NOVAOS_TOOL_TIMEOUT_S (or 128s) — see docs/timeout-hierarchy.md.

None
gateway_timeout_s int | None

Optional opt-in validation floor — raises ValueError if an effective timeout is shorter than it (the SDK should wait at least as long as the Gateway does). None (default) disables validation.

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:ingest or found in a :class:BatchIngestResponse.

required

Returns:

Type Description
IngestJobStatus

class:IngestJobStatus reflecting the latest backend state.

IngestJobStatus

index is set to 0 — it carries no meaning for single-job

IngestJobStatus

queries; use :class:BatchIngestResponse for index-aware tracking.

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 collection key already present in item.metadata).

None

Returns:

Type Description
BatchIngestResponse

class:BatchIngestResponse with per-item :class:IngestJobStatus

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:ingest_batch call.

required
poll_interval_s float

Seconds to wait between polling rounds.

2.0
max_wait_s float

Maximum total wait before :class:TimeoutError is raised. Default 600 s (10 minutes) — increase for very large documents.

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 Authorization header.

required
tenant_id str

Tenant UUID for the X-Tenant-ID header.

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 timeout_s. Defaults to NOVAOS_TOOL_TIMEOUT_S (or 128s) — see docs/timeout-hierarchy.md.

None
gateway_timeout_s int | None

Optional opt-in validation floor applied to every binding — raises ValueError if an effective timeout is shorter than it (the SDK should wait at least as long as the Gateway does). None (default) disables validation, since there is no single platform-wide Gateway timeout to assume.

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
from novaos_client_sdk.client import Client

async with Client(base_url="http://my-agent:8000") as client:
    task = await client.send_message("Hello", skill_id="chat")
    print(task.artifacts[0].text if task.artifacts else task.status.state)

Initialize the Client.

Parameters:

Name Type Description Default
base_url str

Base URL of the A2A agent (e.g., "http://localhost:8000"). Under A2A the URL identifies the agent; there is no per-call agent name.

required
timeout float | None

Request timeout in seconds. Applied to the non-streaming calls only — see :meth:send_streaming_message.

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 A2A-Extensions header and whatever data each one publishes travels in the request metadata map under its own URI. Activating an extension the agent does not declare is harmless — a conformant agent ignores it — but :meth:ExtensionRegistry.negotiate_card will say so in advance if the agent's card has already been fetched.

None
card AgentCard | None

The agent's :class:~novaos_client_sdk.a2a_agent_card.AgentCard, if one has already been fetched — :meth:AgentDiscoveryService.to_client <novaos_client_sdk.discovery.AgentDiscoveryService.to_client> passes the card it cached rather than re-fetching it. None is a normal, fully supported client: a Client built from nothing but a URL never depended on discovery and still does not.

None
config ClientConfig | None

Per-client defaults layered under each call's own keyword arguments. None behaves exactly like ClientConfig().

None
tenant str | None

Explicit tenant to send on every request (A2A v1.0.1, AgentInterface.tenant — routing for an agent that multiplexes several tenants behind one endpoint). When None (the default), the tenant is read instead from card: the interface whose url matches base_url, or failing that the JSON-RPC interface, whichever declares one. Pass this explicitly when constructing a Client directly from a URL with no card — that client has nothing to read a tenant from otherwise — or to override what the card declares. An explicit value always wins over the card's.

None
interceptors list[ClientCallInterceptor] | None

Cross-cutting behaviour run around every call, in list order, through :meth:_execute / :meth:_execute_stream — see :class:~novaos_client_sdk.interceptors.ClientCallInterceptor. None (the default) runs none, producing the exact same request this client made before interceptors existed; passing one is how a caller wires in, for example, :class:~novaos_client_sdk.auth_interceptor.AuthInterceptor for a credential that can refresh mid-session, which the constructor's own auth= cannot do — see this class's __init__ comment on _transport_headers below.

None
protocol_binding str

Which A2A binding this client speaks at base_url — one of :data:~novaos_client_sdk.a2a_agent_card.PROTOCOL_BINDING_JSONRPC (the default, preserving this client's original JSON-RPC-only behavior), :data:~novaos_client_sdk.a2a_agent_card.PROTOCOL_BINDING_HTTP_JSON or :data:~novaos_client_sdk.a2a_agent_card.PROTOCOL_BINDING_GRPC. :meth:~novaos_client_sdk.discovery.AgentDiscoveryService.to_client sets this from whichever interface :func:~novaos_client_sdk.a2a_agent_card.select_interface chose; a Client built directly from a URL defaults to JSON-RPC, same as always.

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:~novaos_client_sdk.dns.validate_agent_url and the real connection (DNS rebinding). False (the default) leaves a directly-built client unchanged, so localhost still works; to_client helpers turn it on. For gRPC it pins the channel to the checked address unless config.grpc_channel_factory builds the channel, which the SDK cannot pin.

False
allow_private_ips bool

With guard_connect_ips, skip the range check (a trusted internal registry entry). Ignored otherwise.

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:send_message.

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:send_hitl_response.

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:send_message.

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. config.task_id is the task to watch; config.id names this registration, since a task may carry more than one (§3.1.7) — leave it empty to let the agent assign one, which is what most agents expect for a first registration. config.token and config.authentication.credentials, if set, are secrets: see :mod:novaos_client_sdk.a2a_push_notification. config.tenant, if set, is sent as given — an explicit value on the object itself wins, the same precedence :meth:send_message gives an explicit per-call argument over a client default; only when the caller left it unset does this client's own resolved :attr:tenant fill it in.

required
context ClientCallContext | None

As :meth:send_message.

None

Returns:

Type Description
TaskPushNotificationConfig

The registration as the agent stored it — read .id off this

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 -32003).

TaskNotFoundError

If the agent does not know config.task_id.

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:send_message.

None

Raises:

Type Description
PushNotificationNotSupportedError

If the agent does not support push notification config (A2A -32003).

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:send_streaming_message or :meth:subscribe_to_task.

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:send_message, :meth:get_task, or any other operation on this client.

required

Returns:

Type Description
dict[str, list[Any]]

Dictionary with keys 'hitl', 'error', 'progress',

dict[str, list[Any]]

'hitl_response'; each value is a list of extracted messages.

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:send_message.

None
context ClientCallContext | None

As :meth:send_message.

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 -32001).

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:create_task_push_notification_config or :meth:list_task_push_notification_configs.

required
context ClientCallContext | None

As :meth:send_message.

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 -32003).

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 next_page_token it returned.

None
context ClientCallContext | None

As :meth:send_message.

None

Returns:

Type Description
TaskPushNotificationConfigList

One page, as a

TaskPushNotificationConfigList

class:~novaos_client_sdk.a2a_push_notification.TaskPushNotificationConfigList.

Raises:

Type Description
PushNotificationNotSupportedError

If the agent does not support push notification config (A2A -32003).

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:~novaos_client_sdk.a2a_task.TaskState or its wire string.

None
page_size int | None

Maximum tasks per page.

None
page_token str | None

Continue a previous page, using the next_page_token it returned.

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:send_message.

None

Returns:

Type Description
TaskList

One page, as a :class:~novaos_client_sdk.a2a_task.TaskList. A

TaskList

non-empty next_page_token means there is more.

Raises:

Type Description
UnsupportedOperationError

If the agent does not implement the method (A2A -32004).

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 contextId.

required
task_id str | None

The waiting task's id, from the Task that asked.

None
input_value str | None

Optional input value (for input_request responses).

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:send_message.

None

Returns:

Type Description
Task | A2AMessage

The task as it stands after the agent processed the response.

Example
task = await client.send_message("Deploy it", skill_id="ops")
hitl = client.extract_hitl_messages(task)[0]

resumed = await client.send_hitl_response(
    action_id="confirm",
    session_id=client.extract_session_id(hitl),
    task_id=task.id,
    content="User confirmed the plan",
)

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:~novaos_client_sdk.a2a_types.A2AMessage, the SDK's :class:~novaos_client_sdk.models.Message convenience dataclass, or a plain string.

required
skill_id str | None

Which of the agent's skills should run. A2A has no dedicated field for this, so it travels as message.metadata["skillId"] — the one placement every NovaOS pod implementation reads. A single-skill agent does not need it; a multi-skill agent rejects a request that names no skill rather than guessing.

None
context_id str | None

A2A contextId, grouping several tasks into one conversation. This is what ACP called session_id.

None
task_id str | None

Continue an existing task rather than starting a new one — how a caller answers a task sitting in TASK_STATE_INPUT_REQUIRED.

None
metadata dict[str, Any] | None

Request-level metadata (params.metadata), distinct from the message's own.

None
return_immediately bool

A2A configuration.returnImmediately. False (the default, and the spec's) asks the agent to hold the response until the task is terminal or interrupted; True asks for the task back as soon as it exists, to be followed with :meth:subscribe_to_task or polled with :meth:get_task. The sense is inverted from A2A v0.3's blocking — v1.0 flipped both the name and its meaning, so a v0.3-era True means this False.

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 configuration.acceptedOutputModes — the client half of A2A content negotiation. Like history_length, this is a request, not a guarantee: the spec lets the agent decline and answer with a mode not on the list. None (the default) falls back to the :class:ClientConfig this client was constructed with, if any; an explicit [] is indistinguishable on the wire from omitting the argument entirely and is likewise sent as no key at all. A non-empty value here always replaces the ClientConfig default rather than merging with it.

None
task_push_notification_config TaskPushNotificationConfig | None

Register (or replace) a webhook to be notified of this task's status changes, sent as configuration.taskPushNotificationConfig. None (the default) falls back to the :class:ClientConfig this client was constructed with, if any — see :attr:ClientConfig.push_notification_config. Check card.capabilities.push_notifications before relying on this: an agent that does not support it answers :class:~novaos_client_sdk.exceptions.PushNotificationNotSupportedError (A2A -32003).

None
context ClientCallContext | None

This call's :class:~novaos_client_sdk.interceptors.ClientCallContext, read by this client's interceptors=. None (the default) runs them against a fresh, empty one.

None

Returns:

Name Type Description
The Task | A2AMessage

class:~novaos_client_sdk.a2a_task.Task the agent created, or

an Task | A2AMessage

class:~novaos_client_sdk.a2a_types.A2AMessage in the case the

Task | A2AMessage

spec allows — an agent answering without creating a task. Both are

Task | A2AMessage

part of the contract; isinstance is how a caller tells them

Task | A2AMessage

apart, since that is what the oneof on the wire means.

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 task nor a message, and no bare task id either (A2A -32006) — the agent's reply cannot be a valid A2A response at all.

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:send_message.

required
default Task | A2AMessage | None

Value to return on the two handled errors.

None
**kwargs Any

Forwarded to :meth:send_message.

{}

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. AgentBuilder implements SendStreamingMessage for real (in handle_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 every AgentBuilder-backed pod unconditionally.
  • A client with no card still streams. Discovery is optional, not a precondition — see :meth:__init__'s card parameter — so a missing card is not evidence the agent lacks the capability, and treating it as evidence would regress every caller who builds a Client directly 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:send_message.

required
skill_id str | None

As :meth:send_message.

None
context_id str | None

As :meth:send_message.

None
task_id str | None

As :meth:send_message.

None
metadata dict[str, Any] | None

As :meth:send_message.

None
history_length int | None

As :meth:send_message.

None
accepted_output_modes list[str] | None

As :meth:send_message.

None
task_push_notification_config TaskPushNotificationConfig | None

As :meth:send_message.

None
context ClientCallContext | None

As :meth:send_message, except after runs once per frame here rather than once total — see :meth:_execute_stream.

None

Yields:

Name Type Description
AsyncIterator[StreamFrame]

Each decoded stream frame — or, on the fallback path, exactly the

frames AsyncIterator[StreamFrame]

meth:_send_streaming_message_fallback produces.

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 -32601, which is a transport-layer condition rather than an A2A one.

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:send_streaming_message.

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 AgentBuilder pod does not.

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 url; mutually exclusive with it. Base64-encoded by :meth:to_dict, so pass the file's bytes (bytes, bytearray or memoryview), not an encoded string. Left out of repr so a large file does not flood logs.

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., "started", "completed").

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., http://execution-gateway:8081).

required
auth AuthProvider | None

Authentication provider (typically BearerTokenAuth with a NovaOS IAM token).

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:execute).

required
last_event_id str | None

Resume cursor from a previous connection's last yielded :attr:StreamEvent.id. Sent as the Last-Event-ID SSE header. Omit for a fresh subscription.

None

Yields:

Type Description
AsyncIterator[StreamEvent]

StreamEvent objects. chunk carries streamed text; done,

AsyncIterator[StreamEvent]

error and a terminal status end the run; hitl,

AsyncIterator[StreamEvent]

connector.oauth_required, tool.write_confirmation_required,

AsyncIterator[StreamEvent]

tool.call/tool.result and any future type are passed through.

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:execute).

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 url or inline data bytes. Omitted from the request when None or empty.

None

Returns:

Type Description
ExecuteResult

ExecuteResult with the run_id for event subscription.

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 attachments is not an :class:Attachment.

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 — "chunk", "done", "error", "status", "hitl", or any other type the gateway forwards (see :meth:from_sse_data).

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 "status" event, the run lifecycle status it reports — None for every other event type. This is what makes a failure that arrives as a status update visible instead of silent; see :attr:is_failure.

id str | None

The SSE id: line delivered with this event, if any — the resume cursor a reconnect sends back as Last-Event-ID (see :meth:GatewayClient.events). This is not the JSON body's sequence field: on a Service Runs stream those are two different counters that happen to share the same plain-integer shape — sending sequence back as Last-Event-ID can silently resume from the wrong place. Use id for reconnects; treat sequence as ordering metadata only.

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 complete
  • error → stream failed
  • status → run lifecycle status, or agent progress
  • hitl → 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 event: field value.

required
data str

The SSE data: field value (JSON string).

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.

client = await discovery.to_client(allowed_host_suffixes=[".partner.example"])

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 {base_url}/.well-known/agent-card.json.

required
cache_ttl int | None

Seconds to reuse a fetched card for. None disables caching.

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 signatures against jwks_url after every fetch, before the card is cached or handed to :meth:to_client. Opt-in and False by default — every card this SDK has consumed so far comes from NovaOS's own gateway over a trusted path, and an unsigned card from that path must keep working. Turn this on once a fetched card may come from a third-party agent, per this module's docstring. Requires the crypto extra: pip install 'novaos-client-sdk[crypto]'.

False
jwks_url str | None

JWKS endpoint to verify against. Required when verify_signatures is True.

None
signature_algorithms Sequence[str] | None

Acceptable JWS algorithms. See :data:novaos_client_sdk.a2a_card_signing.DEFAULT_SIGNATURE_ALGORITHMS for the default.

None
guard_connect_ips bool

Resolve base_url's host at connect time, refuse a private/loopback/link-local/reserved address, and connect to the address that was checked, for the card fetch itself. Opt-in and False by default so a local http://localhost:8000 agent keeps working. Turn it on whenever base_url comes from a user or another untrusted source, since the request carries the auth headers.

False
allow_private_ips bool

With guard_connect_ips, skip the range check (e.g. a gateway that legitimately lives on a private network). Ignored otherwise.

False

Raises:

Type Description
NovaOSValidationError

If verify_signatures is True and no jwks_url was given.

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:~novaos_client_sdk.a2a_agent_card.AgentCard.

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 verify_signatures=True and the fetched card carries no signatures.

InvalidAgentCardSignatureError

If verify_signatures=True and none of the card's signatures verify against jwks_url.

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:novaos_client_sdk.client.SKILL_ID_METADATA_KEY.

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 ([".partner.example"]). Required: an empty or missing list raises.

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:~novaos_client_sdk.client.Client the same as every other client_kwargs entry.

None
**client_kwargs Any

Forwarded to :class:~novaos_client_sdk.client.Client (e.g. timeout, headers, auth).

{}

Returns:

Type Description
Client

A client addressed at the selected interface. It is not entered —

Client

async with await discovery.to_client() as 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:AgentDiscoveryService.__init__.

False
jwks_url str | None

See :meth:AgentDiscoveryService.__init__.

None
signature_algorithms Sequence[str] | None

See :meth:AgentDiscoveryService.__init__.

None

Returns:

Type Description
AgentCard

The parsed :class:~novaos_client_sdk.a2a_agent_card.AgentCard.

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 trust_url to :meth:AgentDNSRecord.to_client when :meth:get_agent_client builds a client from a looked-up record. Defaults to False — see that method's "Trust is opt-in, not the default" for why: the sanctioned way to reach a NovaOS agent is gateway-mediated, not a direct dial, so this SDK does not default to enabling a direct one. Pass True only when this client's caller knowingly needs a direct dial to whatever Agent-DNS returns.

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:AgentDNSRecord.to_client (e.g. timeout, headers, auth).

{}

Returns:

Name Type Description
A Client

class:Client configured with the agent's URL.

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:validate_agent_url before client construction. This is always enforced, regardless of trust_url — it narrows, it never widens.

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 trust_url/trust_dns_urls to :meth:AgentDNSRecord.to_client (or :class:AgentDNSClient, for a name lookup). Defaults to False — see AgentDNSRecord.to_client's "Trust is opt-in, not the default" for why: the golden path to a NovaOS agent is gateway-mediated, not a direct dial to whatever Agent-DNS returns, so this convenience function does not default to enabling one. Pass True only when the caller knowingly needs a direct dial.

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 (https://agent.example.com), but a gRPC interface's URL may be a bare host:port authority with no scheme at all — the conventional shape of a gRPC target. urlparse alone misreads that as scheme host with no hostname (urlparse("agent.example.com:50051").hostname is None), which would make this guard silently validate nothing rather than reject it; see the scheme-less retry below.

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 AgentDNSRecord.to_client's trust_url for the caller-facing distinction; defaults to False here so every direct caller of this function stays guarded unless it opts in.

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. urlparse itself raises a bare ValueError for some malformed input (caller-controlled callers should not have to know that as a separate case from every other rejection this function makes), so that is caught here and re-raised as NovaOSError too.

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:

  1. 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:Client resolves tenant from the card once at construction and sends it on every call (see client.py's _tenant_from_card/_apply_tenant) — so this SDK was already conformant with 1.0.1's tightened text before this bump landed.
  2. The HTTP binding's docs now prefer application/a2a+json over application/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 keeps Content-Type: application/json in both spec releases, so nothing here changes.
  3. A separate fix ("recent transcoding-related error changes", #1627) loosens the JSON-RPC error.data contract — 1.0.0's text had, by the fixing PR's own account, mistakenly required a strict google.rpc.ErrorInfo shape in JSON-RPC error data; 1.0.1 reverts that to "any object with an @type key". This SDK never depended on the stricter reading: :func:~novaos_client_sdk.exceptions.a2a_error_from_json_rpc has always stored error.data verbatim as details["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. TaskNotCancelableError 409→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:

  • Part has no kind discriminator. It is a oneof over text, raw, url and data, with filename, mediaType and metadata as siblings available to every variant. A part is identified by which content field is present.
  • Message has no kind field either.
  • Role serialises as ROLE_USER / ROLE_AGENT.
  • Text parts carry their media type in a real mediaType field, 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: text for human-readable text, data for structured JSON, raw/url for binary or referenced files.
  • ACP's session_id maps to A2A's contextId.

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 Any rather than dict on purpose: this is public API over untrusted wire data, and a signature promising dict would invite callers to skip the check this function already performs.

required

Returns:

Type Description
Part

The matching part dataclass. A part carrying none of the known content

Part

fields falls back to :class:TextPart so a forward-compatible sender (a

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 .items(), or None.

required

Returns:

Type Description
str

The requested version string, or :data:A2A_ABSENT_VERSION.

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 .items(), or None.

required

Returns:

Type Description
str | None

A human-readable reason, or None when the version is servable.

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 Task JSON object. Typed Any because this is public API over untrusted wire data.

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 ListTasksResponse JSON object. Typed Any because this is public API over untrusted wire data.

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 TaskStatus JSON object. Typed Any because this is public API over untrusted wire data.

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.parts is the same Part union messages use, so structured output is a real :class:~novaos_client_sdk.a2a_types.DataPart holding a JSON object, a file is a :class:~novaos_client_sdk.a2a_types.FilePart, and prose is a :class:~novaos_client_sdk.a2a_types.TextPart with its own mediaType. Under ACP a structured result was a JSON string inside a text part.
  • Named: :attr:Artifact.artifact_id is the identity (unique within a task, generated when not supplied — the spec marks it REQUIRED), with :attr:Artifact.name and :attr:Artifact.description as the human-readable labels.
  • Versioned: :attr:Artifact.revision, plus :meth:Artifact.revised to advance it while keeping the identity. See below for why the revision travels in metadata rather 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 Any because this is public API over untrusted wire data.

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 True, parts extends the existing content rather than replacing it — the same distinction A2A's TaskArtifactUpdateEvent.append draws between a chunk of a streamed artifact and a wholesale update of it.

False

Returns:

Type Description
Artifact

A new :class:Artifact; the receiver is left unmodified.

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

True when the frame's state is terminal or an interrupt.

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 data: payload.

required

Returns:

Type Description
StreamFrame | None

The frame's populated member, or None.

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 Any because this is public API over untrusted wire data.

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, :data:PROTOCOL_BINDING_GRPC or :data:PROTOCOL_BINDING_HTTP_JSON. Defaults to JSON-RPC.

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:novaos_client_sdk.client.SKILL_ID_METADATA_KEY.

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 supported_interfaces, in the order the card lists them.

required
supported_bindings list[str]

The bindings a client is able to speak, most preferred first. An empty list matches nothing — pass [PROTOCOL_BINDING_JSONRPC] for this SDK's original JSON-RPC-only behavior, which is what an unset :class:~novaos_client_sdk.client.ClientConfig resolves to.

required
use_client_preference bool

See above.

False

Returns:

Type Description
AgentInterface | None

The chosen :class:AgentInterface, or None if no interface's

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 bytes (e.g. from an ASGI await request.body()) or str.

required
max_body_bytes int

Reject bodies larger than this before attempting to decode them. Defaults to :data:DEFAULT_MAX_BODY_BYTES.

DEFAULT_MAX_BODY_BYTES

Returns:

Type Description
Any

(decoded_value, None) on success, else (None, response_dict).

dict[str, Any] | None

response_dict["id"] is always None per JSON-RPC 2.0 §5.1: the

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 id from the request, or None when the request was unparseable enough that no id could be read

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, since JSON-RPC treats the member's presence as meaningful.

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]

(JsonRpcRequest, None) when valid, else (None, response_dict).

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]

(result, None) on success, (None, error_object) on failure.

Raises:

Type Description
ValueError

If body is not a JSON object at all — a response that is a list or a scalar is not something a caller can degrade past.

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 METHOD_* constants above. A2A v1.0 names are PascalCase; v0.3's message/send earns a -32601 from a v1.0 peer.

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 Task

None
message Any

A serialized Message, for an agent replying without creating a task at all

None

Returns:

Type Description
dict[str, Any]

The result value for a SendMessage JSON-RPC response.

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 id from the request, echoed back verbatim

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.declarations produces the capabilities.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_extensions is where a pod hands it over.
  • Negotiation — the A2A-Extensions request header carries the URIs a caller wants active. :meth:ExtensionRegistry.request_headers is the calling side, :meth:ExtensionRegistry.activate the serving side, and :class:ExtensionActivation is the outcome both read.
  • Dispatch — an activated extension's data arrives at metadata[<uri>]; :meth:ExtensionActivation.value routes 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 capabilities.extensions[] entry for this extension.

params() -> dict[str, Any] | None

Extension-specific configuration to publish in the declaration.

Returns:

Type Description
dict[str, Any] | None

A JSON object for AgentExtension.params, or None to publish

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 None when the caller activated the extension but published no data for it.

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 None to publish nothing. The

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 — declared required but not activated. This is an error, and :meth:unmet_requirement_reason words 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:value through.

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]

{"A2A-Extensions": …} when anything is active, and an **empty

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 None when

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

None if the extension is not active or is unknown here.

Any

Otherwise whatever its :meth:A2AExtension.read_value returns —

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:declarations on its card, and calls :meth:activate per request;
  • a caller registers what it intends to use, sends :meth:request_headers and :meth:request_metadata, and may check itself against an agent's card with :meth:negotiate_card first.

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 uri, or two claim the same one.

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 .items(). Pass the original headers object where there is one: a multi-map flattened into a dict loses all but one of several repeated A2A-Extensions lines.

required
*metadata Any

The request's metadata maps, most authoritative first — for SendMessage that is the request params metadata and then the message's own. Only used to answer :meth:ExtensionActivation.value.

()

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.ignored and the

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:novaos_client_sdk.discovery.AgentDiscoveryService.agent_card returns it.

required

Returns:

Type Description
ExtensionActivation

attr:~ExtensionActivation.activated — declared by the agent and

ExtensionActivation

implemented here, which is exactly what to send;

ExtensionActivation

attr:~ExtensionActivation.ignored — implemented here but not

ExtensionActivation

declared by this agent, so sending them would be noise;

ExtensionActivation

attr:~ExtensionActivation.unmet_required — required by the agent

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.value on it is

ExtensionActivation

always None.

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 extension.uri is empty, or another extension has already claimed it. A duplicate is refused rather than overwritten because a URI is the agreement: two implementations under one URI means a caller's activation silently reaches whichever module was imported last.

request_headers() -> dict[str, str]

Headers activating every registered extension.

Returns:

Type Description
dict[str, str]

{"A2A-Extensions": …}, or an empty dict when nothing is

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 "" when nothing was supplied —

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 .items(), or None. A value may be a string or a list of strings, which is what header multi-maps yield for a repeated header. Prefer passing the original headers object rather than a dict built from it: collapsing a multi-map into a dict keeps only one of several repeated header lines.

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 Task.id this updater owns.

required
context_id str

The A2A contextId grouping this task with related ones.

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 True, extends the existing artifact of this id instead of replacing its content — see :meth:novaos_client_sdk.a2a_artifact.Artifact.revised.

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_task.TERMINAL_TASK_STATES state through this updater. A terminal task does not change state again; the interrupt states are exempt from this — see the module docstring.

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 asyncio.Queue. 0 (the default) means unbounded — see the module docstring for why an unconsumed queue must never apply backpressure today.

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 AuthenticationInfo JSON object. Typed Any because this is public API over untrusted wire data.

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 TaskPushNotificationConfig JSON object. Typed Any because this is public API over untrusted wire data.

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 ListTaskPushNotificationConfigsResponse JSON object. Typed Any because this is public API over untrusted wire data.

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
from novaos_client_sdk.auth import APIKeyAuth
from novaos_client_sdk.client import Client

# Application provides API key
api_key = "your-api-key"

# SDK formats and uses it
auth = APIKeyAuth(api_key=api_key)
client = Client(base_url="http://localhost:8000", auth=auth)
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., {"X-API-Key": "<key>"}).

AuthProvider

Bases: ABC

Abstract base class for authentication providers.

All authentication providers must implement this interface to provide authentication headers for HTTP requests.

Example
class CustomAuth(AuthProvider):
    def get_headers(self) -> dict[str, str]:
        return {"X-Custom-Auth": "value"}

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 Authorization: Bearer <token> header.

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 securitySchemes key :class:~novaos_client_sdk.auth_interceptor.AuthInterceptor resolved for this call — empty string if the call has no :class:~novaos_client_sdk.a2a_agent_card.AgentCard to read a scheme from at all.

required
context ClientCallContext

This call's :class:~novaos_client_sdk.interceptors.ClientCallContext.

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

AuthInterceptor to format into a header using the scheme's

str | dict[str, str] | None

declared shape; or a ready-made {header: value} mapping to use

str | dict[str, str] | None

unchanged, which is how a wrapped synchronous AuthProvider

str | dict[str, str] | None

answers, since it already knows its own header shape; or None

str | dict[str, str] | None

to say this service has nothing for this scheme, which

str | dict[str, str] | None

AuthInterceptor reads as "fall back to the constructor's

str | dict[str, str] | None

AuthProvider".

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 context.state["session_id"] on the calls this credential should answer; "" for the no-session default.

required
scheme_name str

The Agent Card securitySchemes key this credential answers.

required
credential str

The bare credential value — see :meth:CredentialService.get_credentials.

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
import os
from novaos_client_sdk.auth import get_api_key_from_env

# Set environment variable
os.environ["ACP_API_KEY"] = "your-api-key"

# Get auth from environment
auth = get_api_key_from_env()
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:

  1. If a :class:~novaos_client_sdk.auth.CredentialService was configured, pick the scheme to ask it for by matching the call's :class:~novaos_client_sdk.a2a_agent_card.AgentCard securitySchemes against its securityRequirements — see :meth:_select_scheme — and call get_credentials(scheme_name, context).
  2. 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.
  3. Otherwise (no CredentialService configured, no card to read a scheme from, or the service returned nothing), fall back to the constructor's synchronous AuthProvider — exactly what :class:~novaos_client_sdk.client.Client did before this ticket, so a caller who supplies only auth= and no CredentialService sees 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 Client(auth=...). Used as the fallback here rather than a second, independent auth path — a caller migrating onto interceptors keeps exactly the provider they had.

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
class RequestLogger(ClientCallInterceptor):
    async def before(self, args: BeforeArgs) -> None:
        print(f"-> {args.method}")

    async def after(self, args: AfterArgs) -> None:
        print(f"<- {args.method}: {'error' if args.error else 'ok'}")

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. ["novaos.example.com", "localhost"]),

list[str] | None

or None if the env var is unset or empty — meaning no restriction.

Example
export NOVAOS_ALLOWED_HOSTS="novaos.example.com,localhost"

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 timeout_s.

Raises:

Type Description
ValueError

If NOVAOS_TOOL_TIMEOUT_S is set but not a positive integer.

Example
export NOVAOS_TOOL_TIMEOUT_S=180

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 get_environment() to detect the current environment.

None

Raises:

Type Description
ValueError

Always — no default exists. The message names AGENT_DNS_URL, ACP_DNS_URL, DNS_SERVICE_HOST (+ DNS_SERVICE_PORT), and the environment-specific ACP_DNS_URL_<ENV> variable as the ways to configure a DNS URL.

Example
from novaos_client_sdk.config import get_dns_url_for_environment

# Raises ValueError unless a DNS variable is configured
url = get_dns_url_for_environment("staging")

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 get_environment() to detect the current environment.

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".

Example
from novaos_client_sdk.config import get_environment

env = get_environment()  # "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 (default), falls back to :func:get_allowed_hosts_from_env.

None

Raises:

Type Description
ValueError

If an allowlist is configured and url's hostname doesn't end with any entry in it.

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 None to skip validation.

required

Raises:

Type Description
ValueError

If gateway_timeout_s is set and timeout_s is shorter than it.

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 InputField objects describing each field.

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 InputType enum values and plain strings for backward compatibility.

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 InputType enum or plain string for backward compatibility.

None
validation InputValidation | dict[str, Any] | None

Optional validation rules (for input_request). Accepts InputValidation dataclass or plain dict.

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 text/plain is recorded in the part's metadata under the part's own mediaType.

_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 oauth payload alongside connector/auth_url.

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 write_confirmation payload alongside call_id/tool_name.

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 InputType enum or plain string for backward compatibility.

None
validation InputValidation | dict[str, Any] | None

Optional validation rules (for input_request). Accepts InputValidation dataclass or plain dict.

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 text/plain is recorded in the part's metadata under the part's own mediaType.

_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 oauth payload alongside connector/auth_url.

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 write_confirmation payload alongside call_id/tool_name.

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 flat A2A* rename those would have had to fight the generic HTTP errors for names.
  • A future protocol migration touches only the A2AProtocolError subtree, 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 and retry_after / details by 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) bound 1.5 to field while ACPClientError("m", 1.5) bound it to retry_after.
  • Attributes — message, retry_after, details and the is_retryable property are present on every subclass. Subclasses may refine is_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:A2AProtocolError itself can report which code it stands for.

None
task_id str | None

Optional A2A Task.id the error refers to

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 error.data payload

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)

Bases: A2AProtocolError

A2A -32005: no overlap between requested and supported content types.

ExtendedAgentCardNotConfiguredError(message: str, *, error_code: int | None = None, task_id: str | None = None, retry_after: float | None = None, details: dict[str, Any] | None = None)

Bases: A2AProtocolError

A2A -32007: an extended Agent Card was requested but none is configured.

ExtensionSupportRequiredError(message: str, *, error_code: int | None = None, task_id: str | None = None, retry_after: float | None = None, details: dict[str, Any] | None = None)

Bases: A2AProtocolError

A2A -32008: the request needs an extension the caller did not activate.

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)

Bases: A2AProtocolError

A2A -32006: the agent returned a response the spec does not allow.

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)

Bases: NovaOSError

Raised when authentication fails.

NovaOSConnectionError(message: str, *, retry_after: float | None = None, details: dict[str, Any] | None = None)

Bases: NovaOSError

Raised when a connection to a NovaOS service or agent fails.

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)

Bases: NovaOSError

Base exception for DNS-related errors.

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)

Bases: NovaOSError

Raised when a rate limit is exceeded.

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)

Bases: A2AProtocolError

A2A -32003: the agent does not support push notification config.

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)

Bases: A2AProtocolError

A2A -32002: the task is in a terminal state and cannot be canceled.

TaskNotFoundError(message: str, *, error_code: int | None = None, task_id: str | None = None, retry_after: float | None = None, details: dict[str, Any] | None = None)

Bases: A2AProtocolError

A2A -32001: the requested task id is unknown to the agent.

UnsupportedOperationError(message: str, *, error_code: int | None = None, task_id: str | None = None, retry_after: float | None = None, details: dict[str, Any] | None = None)

Bases: A2AProtocolError

A2A -32004: the agent does not implement the requested operation.

VersionNotSupportedError(message: str, *, error_code: int | None = None, task_id: str | None = None, retry_after: float | None = None, details: dict[str, Any] | None = None)

Bases: A2AProtocolError

A2A -32009: the requested protocol version is not supported.

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 error.code from an A2A response

required

Returns:

Type Description
type[A2AProtocolError]

The matching :class:A2AProtocolError subclass, or the base 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 error object, i.e. {"code": ..., "message": ...} with an optional "data"

required
task_id str | None

Optional A2A Task.id the error refers to

None

Returns:

Type Description
A2AProtocolError

An instance of the subclass matching error["code"], or of

A2AProtocolError

class:A2AProtocolError carrying that code when it is unrecognized.

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 error object.

required
task_id str | None

Optional A2A Task.id the error refers to.

None

Returns:

Type Description
NovaOSError

A platform error for the standard JSON-RPC codes, an

NovaOSError

class:A2AProtocolError otherwise.

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 None if the error did not come from a JSON-RPC reply.

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:

  1. 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/mcp dispatch envelope opaquely with no additional enforcement.
  2. Performance — Durable-memory lookups sit on the critical path of every agent turn. An unnecessary gateway hop adds p99 latency for all agent invocations.
  3. 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 forwards Authorization: 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 user scope; R2: tenant scope is admin-only) are enforced by the Memory Service's own policy layer.
  • Tenant isolation — all reads and writes include tenant_id from the token claims, enforced at the Memory Service data layer.
  • Memory Service may also enforce memory IAM scopes from the token's scopes claim (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 hint().

status str

pending, consolidated, dead_lettered or expired.

submitted_at str | None

When the service accepted the hint (ISO 8601), if known.

resolved_at str | None

When consolidation resolved it (ISO 8601); None while pending.

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 MEMORY_SERVICE_URL env var.

None
headers dict[str, str] | None

Additional HTTP headers (typically includes Authorization from ExecutionContext.to_auth_headers()).

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 0 -- 0 means "no retries", not "no attempts": the first (and only) attempt's real error is raised on failure instead of retrying.

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
ctx = ExecutionContext.from_headers(request.headers)

async with MemoryClient(headers=ctx.to_auth_headers()) as mem:
    result = await mem.query(scope="user", query="preferences")
    for item in result.items:
        print(item.content)

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 ctx.memory_budget. None leaves the server's own default in place.

None
latency_budget_ms int | None

Per-turn latency budget in milliseconds, if the caller has one. None leaves the server's own default in place.

None
memory_type MemoryType | None

Optional. Only include durable memories written with this type (semantic, episodic or procedural); also narrows run and task data. Omitted: no type filtering.

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 request_id/trace_id from ctx.run_id/ctx.trace_id when those aren't passed explicitly.

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 (user, agent, run).

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".

'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 reason is empty or exceeds 500 characters.

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 ctx.context_id.

None
ctx AgentExecutionContext | None

The calling agent's execution context, used to derive conversation_id when it isn't passed explicitly.

None
priority str

Hint priority (low, normal, high).

'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 conversation_id is omitted and ctx doesn't supply one either.

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 prop_-prefixed proposal ID from WriteResult.memory_id.

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 memory_id.

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 (user, agent, run, tenant).

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 (tags, after, before, classification).

None
memory_type MemoryType | None

Optional. Only return memories written with this type (semantic, episodic or procedural). Applied before top_k, so every match up to top_k comes back. Memories written without a type never match. Shortcut for filters["memory_type"].

None
conversation_id str | None

Optional. Recall durable memories written under this conversation id, ranked by similarity to query. Applies to durable scopes only (user, agent, tenant); ignored for run/task. Shortcut for filters["conversation_id"].

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 — agent.

required
memory_id str

ID of the committed memory (the memory_id on a :class:MemoryItem from :meth:query, not a prop_ proposal ID).

required
version int

The memory's version as last read from :meth:query. Used as an optimistic lock: if the memory changed since, the call fails with HTTP 409 — re-read and retry.

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 content, tags, confidence or custom is given.

NovaOSServerError

HTTP 409 on a stale version; 404 if the memory is not found; 403 if it belongs to another agent; 422 for a scope that cannot be updated or content rejected by the PII/secret screen.

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:hint to 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 (agent or run for agents).

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 (semantic, episodic or procedural). Omitted: nothing extra is stored and the memory matches no memory_type query filter.

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 (inferred, explicit, system, confidential). Omitted: the service default applies.

None
idempotency_key str | None

Optional key for write idempotency (24h TTL). If omitted and ctx is given, derived from ctx.idempotency_key plus a per-execution ordinal (tracked on ctx, so it survives across separate MemoryClient instances) so repeated writes within the same execution get distinct, stable keys instead of colliding on one dedup key.

None
ctx AgentExecutionContext | None

The calling agent's execution context, used to derive idempotency_key when it isn't passed explicitly.

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 user or tenant for agent principals (HTTP 422).

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 None for run and task scope: those are plain key-value reads, never similarity-ranked.

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 prop_-prefixed proposal identifier.

status str

Gate decision — "committed", "pending", or "rejected".

reason str | None

Rejection reason when status is "rejected" (e.g. "pii_detected", "secrets_detected"), otherwise None.

memory_id str | None

The durable memory ID assigned after commit, or None while the proposal is still pending or was rejected.

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 agent).

version int

The memory's new version. Pass it as version on the next update; a stale version is rejected with HTTP 409.

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 agent-scope writes gated by the AgentWriteGate, this is a prop_-prefixed proposal ID, not a durable memory ID. Use :meth:MemoryClient.poll_proposal to resolve the proposal to its committed memory_id once the gate decides.

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. False when memory_id carries a prop_ prefix, meaning the write is a proposal pending AgentWriteGate approval. When False, callers should use :meth:MemoryClient.poll_proposal to obtain the committed row's real memory_id.

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) or GET /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, reads from NOVAOS_GATEWAY_URL env var.

None
headers dict[str, str] | None

Additional HTTP headers — typically {"Authorization": "Bearer <token>"}. A user token or a service-account token both work with get_run_events; see the module docstring.

None
timeout float

Request timeout in seconds (default: 30).

30.0
max_retries int

Maximum retry attempts for :meth:get_run_events (default: 3). :meth:send_debug_control is never auto-retried — see its docstring.

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. If provided, this class will NOT close it on __aexit__ — the caller retains ownership of its lifecycle.

None
Example
async with ObservabilityClient(
    base_url="https://gateway.internal",
    headers={"Authorization": f"Bearer {service_account_token}"},
) as obs:
    page = await obs.get_run_events(run_id)
    for event in page.events:
        print(event.type, event.timestamp)

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:RunEventsPage's next_cursor, to continue pagination.

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

"service" for the service-account route, "user" for the user route (a user token reading its own run), or "auto" (default) to try the service route and fall back to the user route on a 403. The route that worked is remembered for later "auto" calls on this client.

'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 "pause", "resume", "step", "abort".

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 type.

timestamp str

ISO-8601 timestamp of the event.

sequence int | None

Per-run ordering number, or None when the route does not return it (the service route).

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 None if this is the last page. Pass as after= to :meth:get_run_events to continue.

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, reads from NOVAOS_AGENT_OPS_URL env var.

None
headers dict[str, str] | None

Additional HTTP headers — must include {"Authorization": "Bearer <iam-token>"}. See the module docstring on auth: this is an IAM-issued token, not a service-account header.

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 ObservabilityClient.send_debug_control.

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. If provided, this class will NOT close it on __aexit__ — the caller retains ownership of its lifecycle.

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. "0 * * * *").

required
agent_image str

OCI image ref to run on each scheduled fire.

required
agent_image_tag str

Tag for agent_image.

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 MCP_GATEWAY_URL env var.

None
headers dict[str, str] | None

Additional HTTP headers (typically includes Authorization).

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., novaos.affirmations.generate_affirmation).

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.

Example
async with MCPClient(headers=ctx.to_auth_headers()) as mcp:
    result = await mcp.tools_call(
        "novaos.affirmations.generate_affirmation",
        arguments={"theme": "motivation"},
    )
    print(result.text)

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.

Example
async with MCPClient(headers=ctx.to_auth_headers()) as mcp:
    tools = await mcp.tools_list(query="affirmations")
    for tool in tools:
        print(f"{tool.name}: {tool.description}")

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., novaos.affirmations.generate_affirmation).

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 ctx.run_id).

''
agent_id str

Agent ID (from ctx.agent_id).

''
tenant_id str

Tenant ID (from ctx.tenant_id).

''
name str

Logger name (useful when multiple agents share a process).

'novaos.agent'

Returns:

Type Description
Logger

A logging.Logger configured with a JSON stdout handler.

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:

>>> match_capabilities("web scraping", ["Web Scraping", "Data Analysis"], threshold=0.8)
('Web Scraping', 1.0)
>>> match_capabilities("api", ["API Integration", "Data Analysis"], threshold=0.5)
('API Integration', 0.6)

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:

>>> normalize_capability_name("Web Scraping")
'web-scraping'
>>> normalize_capability_name("web_scraping")
'web-scraping'
>>> normalize_capability_name("API Integration")
'api-integration'

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

  • CircuitBreaker is a pure, dependency-free implementation of the classic three-state automaton: CLOSED → OPEN → HALF_OPEN → CLOSED.
  • backoff_delay computes base * 2^(attempt-1) capped at max_s with optional ±50 % multiplicative jitter (on by default).
  • RETRYABLE_HTTP_STATUSES lists 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_s seconds after failure_threshold consecutive 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 → base_s, attempt 2 → 2 * base_s, etc.

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 (default) apply multiplicative jitter in [0.5, 1.0].

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., http://novaos-execution-gateway:8081). Validated against an outbound-host allowlist (NOVAOS_ALLOWED_HOSTS, if set) before any request is made — see :func:novaos_client_sdk.config.validate_host_allowed.

required
audience str

Expected aud claim in the JWT (default: acp-agent).

'acp-agent'
issuer str

Expected iss claim in the JWT — required. Without this, a validly-signed token from an unrelated IAM tenant that happens to share this JWKS endpoint would still verify; PyJWT raises InvalidIssuerError when the token's iss doesn't match. There is deliberately no default that skips this check — pass the issuer your gateway actually issues tokens for.

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 gateway_url. When None (default), falls back to the NOVAOS_ALLOWED_HOSTS env var; when neither is set, no host restriction is applied.

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 Bearer prefix).

required

Returns:

Type Description
dict

Dictionary of decoded JWT claims, including:

dict
  • user_id: The authenticated user
dict
  • tenant_id: The tenant
dict
  • run_id: The execution run ID
dict
  • instance_id: The agent instance ID
dict
  • agent_id: The target agent ID (optional)
dict
  • agent_name: The target agent name (optional)
dict
  • scopes: Allowed scopes (optional)

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 secrets map as it arrived, name → sealed envelope.

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:~novaos_client_sdk.a2a_sealed_envelope.SecretDelivery.

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:~novaos_client_sdk.a2a_pod_keyring.PodKeyring over the mounted key directory, which reads nothing until the first envelope actually needs opening.

None
required bool

Whether a caller must activate this extension to be served at all. Defaults to False, matching the base class: an agent whose secrets are optional stays reachable by a caller that cannot seal any. An agent that cannot function without them should pass True and get a clean -32008 instead of a skill failing somewhere deeper.

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 None when the caller activated the extension but published nothing — which is ordinary: a fleet run for an instance that declares no secrets activates the extension and sends no payload, and that is "no secrets", not an error.

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.exceptions.SecretNotDeliveredError pointing at the wrong problem.

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 value is not unpadded base64url, or decodes to the wrong length.

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 envelope is not an object with a non-empty string kid.

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 dict parsed from the request's metadata.

required
private_key X25519PrivateKey

The recipient private key, held only by this pod.

required
recipient_public bytes

The 32 raw bytes of private_key's public half. Passed in rather than re-derived so a keyring that already holds it does not recompute it per secret.

required
kid str

The key id private_key is filed under. Checked against the envelope before anything is decrypted.

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:AGENT_KEYS_DIR_ENV environment variable, then to :data:DEFAULT_AGENT_KEYS_DIR.

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:~novaos_client_sdk.a2a_sealed_envelope.SecretDelivery for why it should be built from sidecar-vouched values where they exist.

required

Returns:

Type Description
str

The secret value.

Raises:

Type Description
RecipientKeyUnavailableError

This pod holds no key with the envelope's kid. Usually a key that rotated out of its overlap window or was revoked; possibly a missing mount.

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 x member.

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:AgentCard (its :meth:~AgentCard.to_dict is used) or, preferably for a fetched card, the raw JSON mapping it was parsed from. See the module docstring for why the raw mapping is the sound choice there. Not mutated either way.

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:AgentCard or, for a fetched card, the raw JSON mapping it was parsed from (the sound choice there; see the module docstring). Not mutated, regardless of outcome.

required
jwks_url str

JWKS endpoint to fetch verification keys from. This is caller-supplied trusted configuration, the same trust class as token_verifier.JWKSTokenVerifier's gateway_url — never a URL read out of the card itself (see the module docstring).

required
algorithms Sequence[str] | None

Acceptable JWS algorithms. Defaults to :data:DEFAULT_SIGNATURE_ALGORITHMS. Pass this explicitly to narrow it further (e.g. to a single algorithm your publisher uses).

None
timeout float | None

JWKS HTTP fetch timeout in seconds. None uses PyJWT's default.

None
allowed_hosts list[str] | None

Explicit hostname-suffix allowlist for jwks_url. When None (default), falls back to the NOVAOS_ALLOWED_HOSTS env var; when neither is set, no host restriction is applied — see :func:novaos_client_sdk.config.validate_host_allowed.

None

Raises:

Type Description
NoAgentCardSignatureError

card carries no signatures.

InvalidAgentCardSignatureError

card carries signatures but no entry verifies against jwks_url under algorithms.

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 LLM_GATEWAY_URL env var.

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:~novaos_client_sdk.agent_context.AgentExecutionContext.output_schema.

required

Returns:

Type Description
list[str]

A list of human-readable validation error messages, in the order

list[str]

jsonschema discovers them. Empty list means data is valid.

list[str]

A malformed schema (e.g. an invalid regex in a pattern

list[str]

keyword, or a $ref this function refuses to resolve) also

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 if errors: pattern must never crash on it.

Raises:

Type Description
ImportError

If the optional jsonschema>=4.18.0 dependency (or its referencing dependency) isn't installed — including an allowed-but-too-old jsonschema 4.0–4.17 install that lacks the referencing-based Registry API this function needs.

Example
if ctx.output_schema:
    extracted = await extract_with_llm(ctx, ctx.output_schema)
    errors = validate_against_schema(extracted, ctx.output_schema)
    if errors:
        return SkillResult(output=None, error="; ".join(errors))

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 span
  • OTEL_EXPORTER_OTLP_HEADERS — comma-separated key=value pairs 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]".