SDK · 06a

Effector - tools & side effects

Neurons think, Engrams remember, Effectors act. An Effector is the synapse-side participant that services TOOL_CALL and replies with TOOL_RESULT. It is modelled on Engram deliberately - same addressing (effector_id or effector_kind), same mounting (attach_effector), same rule that a failure rides the reply instead of raising a separate ERROR.

This is where MCP servers and every other tool now live. A tool is not a Neuron: a Neuron decides, an Effector does. Effectors do not think either - choosing which tool to call, and reacting to the outcome, is Neuron-side work.

Cosmonapse does not build your tools. There is no @tool registry and no per-tool routing: you get exactly the signal pair, and dispatch tables, MCP sessions, subprocesses, and sandboxing stay in your code.

classmethodEffector.serve(*, effector_id, effector_kind='effector', version=None)

Build an Effector from one decorator. @on_tool_call receives (tool, args) - plus call_id / deadline_ms / trace_id if declared as keyword parameters - and its return value IS the TOOL_RESULT. Handlers run in registration order; the first non-None answers, None falls through, a raise becomes error on the reply.

effector_serve.py
from cosmonapse import Effector, EffectorBinding, Axon, Dendrite

# ── The Effector side: what actually runs ──────────────────────────
FX = Effector.serve(effector_id="fs-effector",
                    effector_kind="filesystem")

@FX.on_tool_call
async def handle(tool, args):
    # Return value IS the TOOL_RESULT  -  no manual publish.
    if tool == "read":
        return {"content": open(args["path"]).read()}
    return None          # fall through to the next handler

worker = Dendrite(synapse=synapse, role="worker")
worker.attach_effector(FX)

# ── The Neuron side: binding + native dialect ──────────────────────
# THE GATE: effectors= without tool_standard= raises ValueError.
brain = Axon(
    neuron_id="brain",
    neuron_fn=Neuron(source="openai", model="gpt-4o"),
    effectors=[EffectorBinding(name="fs",
                              directed_id="fs-effector")],
    tool_standard="codex",
)

# A Neuron that declares call_tool= gets the helper injected, already
# scoped to the running trace  -  the action-side twin of recall/imprint.
async def think(inp, ctx, *, call_tool):
    out = await call_tool("fs", tool="read",
                          args={"path": "/etc/hosts"})
    return {"response": out.result if out.error is None else out.error}
abstract basecosmonapse.Effector

The low-level path: subclass and implement connect / close / invoke when the backend owns real resources. A served Effector is just a concrete subclass with the handler wired for you.

effector.pyi
class Effector(ABC):
    effector_id:   str
    effector_kind: str
    capabilities:  list[str]          # tool names; [] means "everything"
    version:       str | None

    async def connect(self) -> None: ...   # open subprocess / HTTP pool
    async def close(self) -> None: ...     # release them

    async def can_serve(self, tool: str) -> bool: ...
    # Default: tool in capabilities (empty list = serve everything).

    async def invoke(
        self, tool: str, args: dict, *,
        call_id: str | None = None,
        deadline_ms: int | None = None,
        trace_id: str | None = None,
    ) -> ToolOutcome: ...
    # Tool-level failure (bad args, missing file, non-zero exit) is
    # ToolOutcome(error=...)  -  NOT a raise. Raise only for a backend
    # fault; the Dendrite maps it onto TOOL_RESULT error either way.

    @property
    def host(self) -> _EffectorHostProxy: ...
    # @fx.host.on_<signal> queues a Dendrite decorator at module level,
    # replayed onto the HOSTING Dendrite once it connects this Effector.

@dataclass(frozen=True)
class ToolOutcome:
    tool:        str
    result:      Any = None
    error:       str | None = None
    call_id:     str | None = None
    took_ms:     int | None = None
    effector_id: str | None = None

Wiring an MCP server

Neuron(source="mcp") is still the stdio transport - it spawns the server subprocess and speaks the Model Context Protocol. What changed is where it sits: instead of being attached as a Neuron, it is wrapped in an Effector and attached with attach_effector, so the server answers TOOL_CALLs on the trace rather than producing AGENT_OUTPUT of its own.

mcp_effector.py
# An MCP server is an Effector. Neuron(source="mcp") stays as the stdio
# transport underneath  -  the Effector is what goes on the bus.
from cosmonapse import Effector, Neuron, ToolOutcome

class MCPEffector(Effector):
    def __init__(self, *, effector_id, effector_kind, mcp, shape=None):
        self.effector_id = effector_id
        self.effector_kind = effector_kind
        self.capabilities = []      # empty = proxy whatever the server takes
        self._mcp, self._shape = mcp, shape

    async def connect(self):    pass   # the Neuron spawns lazily on first call
    async def close(self):      await self._mcp.aclose()

    async def invoke(self, tool, args, *, call_id=None, **kw):
        # shape() maps a model-facing tool name onto the server's own name
        name, payload = (self._shape(tool, args) if self._shape
                         else (tool, args))
        out = await self._mcp({"tool": name, "arguments": payload}, [])
        if out.get("is_error"):
            return ToolOutcome(tool=tool, call_id=call_id,
                               error=str(out.get("response")))
        return ToolOutcome(tool=tool, call_id=call_id, result=out)

files = MCPEffector(
    effector_id="files-effector", effector_kind="filesystem",
    mcp=Neuron(source="mcp", server="filesystem", args=["/data"]),
)
worker.attach_effector(files)

Native tool-call dialects

Models emit tool calls in their own format. tool_standard= tells the Axon which dialect to recognise in the raw output; TOOL_STANDARDS holds the parsers. They are pure text parsers with an anti-misfire rule - ordinary JSON in a reply never registers as a call, and the first call found wins.

tool_standard=Recognises
"hermes"Nous/Hermes <tool_call>{"name", "arguments"}</tool_call> tags.
"claude"Anthropic {"type": "tool_use", "name", "input"} blocks.
"codex"OpenAI function calling - a tool_calls array, legacy function_call, or a bare exact-keys {"name", "arguments"}. A top-level "type": "function" marker also licenses parameters as the args key, which is how Llama-class models actually reply.

The gate: effectors= requires tool_standard=, or the Axon raises ValueError at construction - a misconfiguration fails at import, not silently at runtime. tool_standard= on its own is pure translation: the recognised call is surfaced on AGENT_OUTPUT as {tool, args} and your host chain executes it. A model’s native call always takes precedence over the Axon’s own recognisers.

Calling a tool

async methoddendrite.call_tool(*, effector_id=None, effector_kind=None, tool, args=None, call_id=None, deadline_ms=None, trace_id=None, parent_id=None, meta=None) -> ToolOutcome

Emit TOOL_CALL and await the correlated TOOL_RESULT. Trace attribution resolves explicit ids first, then the ambient task context, then a fresh trace. With no deadline_ms - and none on the binding - the call waits until the trace terminates; pass a deadline if it must not hang.

injected helpercall_tool(name, *, tool, args=None, call_id=None, deadline_ms=None, meta=None) -> ToolOutcome

A Neuron that declares a call_tool= keyword parameter gets this injected by its Axon, already scoped to the running trace. name is the local EffectorBinding name, not the deployment's effector_id - the action-side twin of the injected recall / imprint helpers. The 30s DEFAULT_TOOL_DEADLINE_MS applies to Axon-dispatched native tool calls, not to this helper.

classcosmonapse.EffectorBinding(name, directed_id=None, directed_type=None, default_deadline_ms=None, tools=None)

Declarative wiring stored on the Axon. name is what the Neuron addresses; directed_id (effector_id) or directed_type (effector_kind) determines routing on the wire. Binding resolution goes tools-list, then name match, then the sole binding.

propertydendrite.effector_client -> EffectorClient

Caller-side correlation table, the action-side twin of engram_client. TOOL_RESULT is always subscribed, replies are correlated by parent_id and call_id.

Errors never terminate the TASK. A tool-level failure comes back as error on the TOOL_RESULT, so the Neuron can read it and try something else. That is also why TOOL_CALL stays in PATHWAY_TYPES: the servicing branch does not consume it, so trace observers and your own on_tool_call handlers still see every call go past. The SDK-level exceptions (EffectorTimeout, EffectorCancelled, EffectorNotBound, EffectorOverloaded) are raised caller-side, not on the wire.

Have a feature in mind?

The protocol, SDKs, and CLI are still pre-1.0. If something here is missing, ambiguous, or wrong - open an issue and propose a change. Every breaking change is debated in DECISIONS.md first.