Skip to content

Types & testing

This page is for code that Noeta calls back into, or that reads what Noeta recorded. It covers the extension interfaces you implement, the event and message types you receive, the @tool authoring API, and the test doubles in noeta.sdk.testing.

If you only want to run an agent, query / Client is enough.

Extension interfaces

Implement one of these and mount it through the matching Options field.

InterfaceMount viaDefined in
Tool — metadata plus invoke(arguments, ctx) -> ToolResultallowed_toolsnoeta/protocols/tool.py
ToolContext / ToolResulta tool's inputs and outputsnoeta/protocols/tool.py
LLMProvidercomplete(request) -> LLMResponseprovidernoeta/protocols/messages.py
StreamingProvider / StreamDeltaimplement alongside LLMProvider; consumed via HostConfig.delta_sinknoeta/protocols/messages.py
Policydecide(ctx, view) -> Decisionpolicynoeta/protocols/policy.py
Guard / GuardContext / VerdictResultguardsnoeta/protocols/hooks.py
ProposedAction and its members ProposedToolCall / ProposedSpawnSubtask / ProposedFinishpassed to Guard.checknoeta/protocols/hooks.py
Observer (an alias for Subscriber, i.e. Callable[[EventEnvelope], None])observersnoeta/protocols/event_log.py
ContentKindSpeccontent_channelsnoeta/context/content_channel.py
Decision — the union a custom Policy returnsreturned by Policy.decidenoeta/protocols/decisions.py
StepContext / Viewpassed to a custom Policynoeta/protocols/step_context.py, view.py

ToolResult carries success, output, summary, artifacts, images, side_effects, output_ref and file_changes. A guard dispatches on the ProposedAction members with isinstance, which is why all three are exported rather than just the union.

MemoryStore (noeta.builtins.memory.impl, lazily re-exported from noeta.sdk) is the file-per-memory store behind the memory tools. A host that manages memory pools opens the same store the agent writes, so both sides agree on slugs and frontmatter.

Authoring tools

@tool

python
from noeta.sdk import ToolResult, tool

@tool(
    name="word_count",
    version="1",
    risk_level="low",
    input_schema={"type": "object", "properties": {"text": {"type": "string"}}},
    description="Count the words in a string.",
)
def word_count(arguments, ctx):
    return ToolResult(success=True, output=str(len(arguments["text"].split())))

print(word_count.name, word_count.risk_level)
# → word_count low

Wraps fn(arguments, ctx) -> ToolResult as a DecoratedTool. name, version and input_schema are required keywords — omitting version raises TypeError, because the version feeds the identity fingerprint. risk_level defaults to "low".

input_schema is LLM-facing metadata, not a runtime validator, and description is the model's single source of truth for what the tool does — never repeat it in the system prompt. The decorator is also callable directly: tool(fn, name=..., version=..., input_schema=...).

create_sdk_mcp_server

python
create_sdk_mcp_server(name, version="1.0.0", tools=()) -> SdkMcpServer

Bundles @tool functions into an in-process ("sdk" transport) MCP server for Options.mcp_servers. An empty name raises ValueError; a non-DecoratedTool entry raises TypeError. SdkMcpServer is frozen, carrying name, version and tools.

Its tools keep their bare @tool names. The mcp__{alias}__{tool} prefix applies to remote servers only — see Connect MCP.

Events and envelopes

An EventEnvelope is one record on a task's stream. The envelope carries seq / type / actor / origin / trace_id / causation_idseq is assigned by the log on append — and the payload is a typed dataclass selected by type.

envelope_to_dict(env) -> dict (client/wire.py) produces the canonical JSON-ready form, which is the shape an SSE stream consumes.

python
from noeta.sdk import envelope_to_dict

for env in client.events(task_id):
    print(env.seq, env.type)
# → 1 TaskCreated
# → 2 ContextPlanComposed
# → 3 MessagesAppended

Message projection

as_messages(envelopes, content_store) -> list[ViewItem] (client/messages.py) is a pure projection of an envelope stream into the human-readable view. The content_store must be the one paired with that stream, because the projection dereferences large bodies through it.

ViewItem is the union of six frozen types:

TypeFields
AssistantMessagetext
UserMessagetext
InjectedMessagetext, origin
ToolUsecall_id, tool_name, arguments
ToolResultViewcall_id, tool_name, success, output: str | None
Resultanswer, status — on "failed", answer holds the failure reason

Each item's type is its author. A user-channel turn the host injected (origin "system" for reminders / injected context, "memory" for cross-task recall) projects as InjectedMessage, never UserMessage — so separating real user input from ambient host context is an isinstance check, and a transcript UI that renders only UserMessage / AssistantMessage shows injected context by opting in, not by accident.

Client.messages(task_id) and QueryResult.messages() call this for you against the right store, so reach for as_messages only when you hold the envelopes and the store yourself.

Content blocks

TypeShapeNotes
ContentRefhash, size, media_typea reference into the ContentStore; lookup is by hash alone
ImageBlocksource: ContentRefan image input block for start / send_goal / query(images=…)
TextBlocktextplain assistant or user text
ToolUseBlockcall_id, tool_name, argumentsthe model asking for a tool
ToolResultBlockcall_id, output, success, error=None, images=Nonethe answer to one ToolUseBlock

A Message is role ("system" / "user" / "assistant" / "tool"), content: list[Block], and an optional origin ("human" / "system" / "memory"). Only the Engine's recording path may write origin; a marker forged in model or tool output is just text.

Provider request and response

An LLMProvider implementation consumes an LLMRequest and returns an LLMResponse.

LLMRequestmodel, messages, tools (provider-shaped schema dicts), system, temperature, max_tokens, metadata, output_schema, thinking, effort.

LLMResponsestop_reason ("tool_use" / "end_turn" / "max_tokens" / "error"), content: list[Block], usage, and an optional raw dict for the untouched vendor payload.

Usage — the token counters the governance fold accumulates:

FieldMeaning
uncachedinput tokens billed at full rate
cache_readinput tokens served from the provider's KV cache
cache_writeinput tokens written into that cache
outputgenerated tokens
reasoning_tokensthinking tokens, where the provider reports them
.input (property)uncached + cache_read + cache_write
.visible_output (property)max(0, output - reasoning_tokens) — the user-facing answer size

Splitting cached from uncached input is what makes the stable-prefix cache measurable — see Composer & cache.

Test doubles

noeta.sdk.testing holds the deterministic, network-free doubles a product drives in its offline suite. They sit in a submodule so a production import can never pull test material in by accident.

FakeLLMProvider

A dataclass with three fields: responses (a scripted list of LLMResponse, iterated in order), received_requests (every LLMRequest it saw), and responder (an optional (request) -> LLMResponse callable).

python
from noeta.sdk import LLMResponse, Options, TextBlock, query
from noeta.sdk.testing import FakeLLMProvider

provider = FakeLLMProvider(responses=[
    LLMResponse(stop_reason="end_turn", content=[TextBlock(text="42")]),
])

result = query(Options(system_prompt="Be terse."), goal="What is 6 times 7?",
               provider=provider, workspace_dir=".")

print(result.answer())                  # → '42'
print(len(provider.received_requests))  # → 1

An exhausted script raises IndexError from complete, so a runaway test fails loudly instead of looping on the last response. complete is thread-safe, but the positional cursor is order-dependent and therefore unusable under concurrency: a test that drives a concurrent group passes a responder that routes by request content instead. The responder runs outside the lock, so a deliberately blocking responder cannot serialise its own callers.

Next

Released under the Apache License 2.0.