Agents
Agents are the core building blocks in Protolink.
Concepts
An Agent is ProtoLink's A2A-first runtime entity. It owns an AgentCard, receives and returns Task objects composed of Message, Part, and Artifact primitives, and can act as both client and server.
It is the core building block of Protolink, responsible for managing identity, capabilities, and interactions between agents. The Agent integrates key components such as tools, LLMs, knowledge and RAG, transport, state, storage, telemetry, and logging.
Agent(..., transport="http", a2a=True) adds A2A 1.0 inbound and outbound translation without changing handle_task(Task) or removing the native ProtoLink endpoints. The default a2a=False preserves the previous native-only behavior.
Agents communicate through Tasks, the fundamental unit of work:
- Receive tasks via
handle_task() - Send tasks via
call_agent()
Agents also register themselves and discover others via the registry. For inference requests ("infer"), ProtoLink automatically manages the full LLM interaction cycle until the task is resolved.
Each component is pluggable to the agent and can be replaced with your own implementation.
High‑level ideas:
- A2A primitives: cards describe agents, tasks carry work, and messages, parts, and artifacts carry instructions and results.
- Unified model: a single
Agentinstance can send and receive messages. - AgentCard: a small model describing the agent (name, description, metadata).
- Modules:
- LLMs (e.g.
OpenAILLM,AnthropicLLM,LlamaCPPLocalLLM,LlamaCPPServerLLM,OllamaLLM). - Tools (native Python functions or MCP‑backed tools).
- Storage (e.g.
InMemoryStorage,SQLiteStorage). - Telemetry (e.g.
LocalTraceTelemetry,LangfuseTelemetry,LangSmithTelemetry). - Logger (e.g.
ConsoleLogger,FileLogger,QuietLogger).
- LLMs (e.g.
- Transport abstraction: agents communicate over transports such as HTTP, SSE JSON-RPC, WebSocket, gRPC, or the in-process runtime transport.

Creating an Agent
A minimal agent consists of three pieces:
- An
AgentCarddescribing the agent. - A
Transportimplementation. - An optional LLM and tools.
Example:
from protolink.agents import Agent
from protolink.models import AgentCard
from protolink.transport import HTTPTransport
from protolink.llms.api import OpenAILLM
# Agent card can be an AgentCard object or a dict for simplicity, both are handled the same way.
# Option 1: Using AgentCard object
agent_card = AgentCard(
name="example_agent",
description="A dummy agent",
url="http://localhost:8000",
)
# Option 2: Using dictionary (simpler)
card_dict = {
"name": "example_agent",
"description": "A dummy agent",
"url": "http://localhost:8000"
}
transport = HTTPTransport(url="http://localhost:8000")
llm = OpenAILLM(model="gpt-4o-mini")
# Both approaches work
agent = Agent(card=agent_card, transport=transport, llm=llm)
# OR
agent = Agent(card=card_dict, transport=transport, llm=llm)
You can then attach tools and start the agent.
When served through an HTTP-compatible transport ("http", "sse", "json-rpc", or "sse-json-rpc"), the Agent and Registry also expose browser pages for local inspection:
- Registry status:
GET <registry-url>/status - Agent status:
GET <agent-url>/status - Agent chat:
GET <agent-url>/chatfor a browser UI, plusPOST <agent-url>/chatfor chat messages when the agent has an LLM
These pages are generated by the built-in status and chat renderers and are mounted from the same server endpoint specs as the JSON APIs. WebSocketTransport and RuntimeTransport keep the same logical endpoint definitions for Protolink clients, but they do not serve normal browser HTML pages directly.
If the agent is registered, run protolink dashboard --registry-url http://localhost:9000 --open to inspect its card, health, and chat support.
Agent-to-Agent Communication
Agents communicate over a chosen transport.
Common patterns:
- RuntimeTransport: agents operate dedicated native local transports connected via a globally shared memory registry. This mirrors distributed HTTP environments perfectly, enabling zero network overhead testing workflows while retaining accurate boundaries.
- HTTPTransport / SSEJSONRPCTransport: agents expose normal HTTP endpoints so other agents, CLIs, dashboards, browser pages, or external clients can send requests. SSE adds streamed task events over
text/event-stream. - WebSocketTransport: agents expose the same endpoint specs over JSON frames on a WebSocket connection. Use this for streamed task events and long-lived clients rather than direct browser page URLs.
Agent Transport Layers
| Layer | Responsibility |
|---|---|
| Agent | Domain logic (what to do with a Task) |
| AgentServer | Wiring & lifecycle (server orchestration) |
| Transport | Protocol abstraction (HTTP, SSE, WS, runtime) |
| Backend | Framework-specific routing (Starlette/FastAPI) |
e.g.
Agent.handle_task() -> AgentServer -> Transport.setup_routes() -> Backend creates route
Agent API Reference
This section provides a detailed API reference for the Agent base class in protolink.agents.base. It is the core component for creating pluggable, A2A-based agents while combining client, server, execution, and runtime modules in one facade. HTTP agents can opt into the dedicated A2A 1.0 adapters described in A2A compatibility.
Protolink's Agent combines client and server functionality in a single class. You can send tasks and messages to peers while also serving incoming requests; protocol-specific translation remains at the server boundary.
The public facade for identity, lifecycle, transport wiring, task execution, tools, knowledge retrieval, LLM inference, state, policy, telemetry, and registry discovery.
protolink.agents.AgentAgent(card, transport, llm)start() / stop()handle_task()call_agent()Implementation Layout
protolink.agents.base.Agent is the stable public facade. Internally, the agent package keeps the constructor and dependency wiring in base.py, the core task and LLM execution loop in engine.py, reusable behavior chunks in mixins.py, state-request normalization in helpers.py, and the blocking convenience facade in sync.py.
Constructor
Agent
Agent(
card: AgentCard | dict[str, Any],
transport: TransportType | Transport | None = None,
registry: TransportType | Registry | RegistryClient | None = None,
registry_url: str | None = None,
llm: LLM | None = None,
system_prompt: str | None = None,
storage: Storage | None = None,
state: list[StateMode] | State | None = None,
telemetry: Telemetry | None = None,
skills: Literal["auto", "fixed"] = "auto",
logger: BaseLogger | None = None,
discovery_ttl: int = 0,
*,
override_system_prompt: bool = False,
verbosity: Literal[0, 1, 2] = 1,
expose_chat: bool = True,
a2a: bool = False,
authenticator: Authenticator | None = None,
credentials: str | None = None,
policy: Policy | None = None,
approval_handler: ApprovalHandlerLike | None = None,
run_store: Any | None = None,
registry_heartbeat_interval: float | None = None,
knowledge: Knowledge | Retriever | Sequence[Knowledge | Retriever] | None = None,
retrieval: Literal["auto", "always", "required"] = "auto",
)Create the stable Agent facade and wire its identity, execution engine, communication clients, server routes, state, tools, policy, and observability dependencies. Construction does not start a server or register the card; call start() or register() explicitly when those side effects are wanted.
Parameters
cardAgentCard | dict[str, Any]requiredIdentity and capability metadata for this Agent. Dictionaries are normalized with
AgentCard.from_dict(). The card URL is also used when ProtoLink must construct a transport from a short alias, so it must match the address or runtime URI at which peers can reach the Agent.transportTransportType | Transport | Nonedefault: NoneInbound and outbound communication layer. A registered alias such as
"http","runtime","websocket", or"grpc"creates a transport with defaults derived fromcard.url. A concrete instance preserves its TLS, retry, limits, keepalive, metrics, and ownership configuration.Nonecreates a local facade with no client or server.registryTransportType | Registry | RegistryClient | Nonedefault: NoneOptional discovery connection. A
Registrycontributes its client, aRegistryClientis used directly, and a transport alias creates a default client atregistry_url. Without one, discovery returns an empty list and registration methods are no-ops.registry_urlstr | Nonedefault: NoneRegistry address used only when
registryis a transport alias. Put advanced TLS and capacity settings on a configured registry transport and pass itsRegistryClientinstead.llmLLM | Nonedefault: NoneOptional language model used for explicit
inferparts. Assignment callsllm.validate_connection()and uses its result to updatecard.capabilities.has_llm. Depending on the adapter, validation may contact a provider or local server during Agent construction.system_promptstr | Nonedefault: NoneAgent-specific role and behavior instructions appended to ProtoLink's runtime prompt. Tool, delegation, flow, and action instructions are compiled separately. Set
override_system_prompt=Trueonly when the supplied text should replace that built-in blueprint.storageStorage | Nonedefault: NonePersistence backend shared by the Agent and its
Stateobject.Nonecreates anInMemoryStoragenamespace based oncard.name.statelist[StateMode] | State | Nonedefault: NonePersistent-state configuration. A list enables selected stores such as
"conversation","tools","task", and"flow"; aStateinstance is adopted directly.Noneis intentionally stateless even though an in-memory storage object still exists.telemetryTelemetry | Nonedefault: NoneObserver receiving task, tool, LLM, and inference events. The setter binds the telemetry object back to this Agent.
skillsLiteral["auto", "fixed"]default: "auto""auto"advertises skills inferred from registered tools while retaining card-defined skills."fixed"leaves the card's declared skill list under application control.loggerBaseLogger | Nonedefault: NoneLogging implementation. When omitted, ProtoLink creates a namespaced
ConsoleLoggerwhose level followsverbosity.discovery_ttlintdefault: 0Seconds to cache registry discovery results per filter. Zero disables caching, so every discovery request reaches the registry.
override_system_promptbooldefault: FalseReplace the generated runtime prompt with
system_promptinstead of treating it as complementary instructions. This can remove built-in action guidance, so use it only when the replacement prompt defines the complete contract.verbosityLiteral[0, 1, 2]default: 1Default Agent log level:
0suppresses ordinary Agent logs,1emits informational lifecycle messages, and2enables debug detail. A suppliedloggerowns its own level.expose_chatbooldefault: TrueAllow the built-in chat handler and browser page when an LLM and HTTP-compatible server are available. It does not create an LLM or transport.
a2abooldefault: FalseEnable the A2A 1.0 translation boundary. The current setter requires the exact HTTP transport; native ProtoLink endpoints remain available. Agent-originated A2A calls enforce same-origin advertised interfaces.
authenticatorAuthenticator | Nonedefault: NoneVerifier for incoming transport requests. Authentication is enforced at the server boundary before task execution.
credentialsstr | Nonedefault: NoneCredential value attached by the outbound client. Treat serialized configurations containing it as sensitive.
policyPolicy | Nonedefault: NoneRuntime policy evaluated before tools, state mutation, history compaction, and other concrete actions.
Noneinstalls an allow-by-defaultCapabilityPolicy, while tool- or run-level capability restrictions can still narrow access.approval_handlerApprovalHandlerLike | Nonedefault: NoneSynchronous or asynchronous application callback used to resolve typed approval checkpoints requested by policy.
run_storeAny | Nonedefault: NoneOptional object implementing the run-store protocol. The engine writes terminal and streamed task snapshots to it; the store is observational and does not replace the process-local active-task registry.
registry_heartbeat_intervalfloat | Nonedefault: NoneSeconds between heartbeats after successful registration.
Nonedisables the loop. Values below0.1are clamped to 0.1 seconds.knowledgeKnowledge | Retriever | Sequence[Knowledge | Retriever] | Nonedefault: NoneOne knowledge source, a structural retriever, or a sequence of sources. Each source becomes a typed
search_<name>tool available to the inference loop. Plain retrievers are wrapped as knowledge named"knowledge"; wrap them inKnowledgeto provide a specific name, description, result limit, or reranker.retrievalLiteral["auto", "always", "required"]default: "auto"Default retrieval behavior for infer tasks.
"auto"lets the model choose a knowledge tool,"always"retrieves before the first model call, and"required"additionally raisesKnowledgeNotFoundErrorwhen no selected source can provide a usable passage inside the bounded model context. Per-task metadata may strengthen this mode but cannot weaken it.
The constructor creates default storage, logger, policy, state, and sync facades and may construct transport clients and routes. LLM connection validation can perform I/O. Attaching knowledge creates retrieval tools and capability metadata, but staged knowledge sources remain lazy until ready() or the first search. Construction does not bind Agent server ports, register the card, or begin heartbeats until lifecycle methods run.
from protolink.agents import Agent
from protolink.models import AgentCard
from protolink.transport import HTTPTransport
from protolink.llms.api import OpenAILLM
url = "http://localhost:8020"
card = AgentCard(name="my_agent", description="Example agent", url=url)
llm = OpenAILLM(model="gpt-4")
transport = HTTPTransport(url=url)
agent = Agent(card=card, transport=transport, llm=llm)
Simple and Advanced Transports
The Agent API uses progressive control. Pass a registered transport name when defaults are sufficient:
agent = Agent(card=card, transport="http", llm=llm)
This is the prototyping path: ProtoLink creates an HTTPTransport from card.url, applies safe default limits, collects local metrics, and leaves retries disabled.
For TLS, resource policies, retries, or protocol-specific constructor options, build the transport explicitly:
from protolink import Agent, AgentCard, RetryPolicy, TLSConfig, TransportConfig, TransportLimits
from protolink.transport import GRPCTransport
card = AgentCard(
name="production-agent",
description="Production task worker",
url="grpcs://agent.internal:9443",
)
transport_config = TransportConfig(
limits=TransportLimits(
max_request_bytes=8 * 1024 * 1024,
max_response_bytes=8 * 1024 * 1024,
max_concurrent_requests=200,
max_concurrent_streams=50,
),
retry=RetryPolicy(max_attempts=3),
shutdown_timeout=10.0,
)
transport = GRPCTransport(
url=card.url,
tls=TLSConfig(
certfile="certs/agent.pem",
keyfile="certs/agent-key.pem",
cafile="certs/ca.pem",
),
config=transport_config,
)
agent = Agent(
card=card,
transport=transport,
)
Agent deliberately does not duplicate tls= or transport_config= arguments. TLS, limits, retries, keepalive, and connection ownership belong to the transport. This keeps the common Agent constructor small and lets the Agent transport and Registry transport use independent certificates and capacity policies.
An Agent uses its concrete transport in both directions: its server receives tasks from peers and its client sends tasks to peers. Inspect that instance through agent.transport; its config, capabilities, metrics, and health() surfaces are documented in the transport reference.
For an advanced Registry connection, construct its transport separately and wrap it in RegistryClient. Passing registry="http" remains the simple default path.
Durable Task Snapshots
Agents remain stateless by default, but production services and CLIs often need a durable record of the task state that was returned to a user. Pass a RunStore implementation to run_store to persist snapshots without changing task execution code.
from protolink import Agent, AgentCard, SQLiteRunStore
store = SQLiteRunStore("runs.db")
agent = Agent(
AgentCard(name="worker", description="Worker", url="runtime://worker"),
llm=llm,
run_store=store,
)
The built-in SQLiteRunStore indexes task ID, state, run ID, session ID, trace ID, and agent name. Applications can implement the same RunStore protocol for Postgres, object storage, or an existing application database. The store is observational: it records completed, failed, canceled, and streamed task snapshots, but active cancellation and live execution still use the process-local task registry.
Lifecycle Methods
These methods control the agent's server component lifecycle.
Agent.start
start(
*,
register: bool = True,
background: bool = False,
) -> NoneStart the configured server, optionally register the Agent, and keep its lifecycle alive. This is a synchronous entry point even though the underlying server and registry operations are asynchronous.
Parameters
registerbooldefault: TrueRegister
cardwith the configured registry after server startup. If registration fails with a connection error, the server remains running but is not discoverable. With no registry client, this flag has no effect.backgroundbooldefault: FalseWhen
False, run the lifecycle withasyncio.run()and block the calling thread. WhenTrue, create a non-daemon thread and a dedicated event loop, wait for startup readiness, then return to the caller.
Returns
NoneNoneBackground mode returns after readiness or the ten-second startup wait. Blocking mode returns only after shutdown.
Raises
startup errorServer startup failures, including address conflicts, propagate to the caller. Background mode captures the exception in its thread and re-raises it after readiness synchronization.
Calling start(background=False) inside an already-running event loop blocks that loop. Use background=True from notebooks, ASGI applications, and other async hosts.
Agent.stop
stop() -> NoneRequest graceful shutdown of an Agent started in background mode and synchronously wait for its lifecycle thread to exit. Cancellation of the private lifecycle task triggers registry unregistration, heartbeat cleanup, and server shutdown.
Returns
NoneNoneReturns after the background thread exits or after the ten-second join timeout.
Repeated calls are safe. stop() is designed around the background lifecycle; normal blocking mode is ordinarily stopped by interrupting the process lifecycle.
Execution Models & Lifecycle
The start() method is the primary entrypoint for running an agent. To provide a "minimal boilerplate" experience, Protolink's lifecycle management automatically isolates the agent's internal async operations when running in the background, making it extremely robust across different environments.
The background Parameter
The background parameter controls the execution mode and event loop isolation:
background=True: Starts the agent in a dedicated background thread with its own isolatedasyncioevent loop. It returns immediately. This is the recommended mode when running agents from Jupyter Notebooks, inside existingasyncioapplications, or when orchestrating multiple agents in a single script.background=False(Default): Takes over the main thread and blocks execution until the agent is stopped (e.g., via Ctrl+C in a terminal). Ideal for standalone agent processes.
Seamless Synchronous Teardown
Because background=True isolates the agent in its own thread, shutting down the agent is incredibly simple. You just call agent.stop().
The stop() method operates synchronously, it tells the background thread to shut down and blocks for a fraction of a second to gracefully close Uvicorn and unregister from the registry. You do not need to await it, and it will never trigger messy CancelledError exceptions in your main event loop.
Common Usage Patterns
1. Standalone Python Script For simple scripts where the agent is the main process, use the default blocking mode:
# This will take over the main thread and block until interrupted
agent.start()
2. Multi-Agent Script If you need to start multiple agents in a single script, run them in the background and gracefully stop them at the end.
agent_a.start(background=True)
agent_b.start(background=True)
# ... interact with agents ...
agent_a.stop()
agent_b.stop()
3. Jupyter Notebooks & Async Apps
Jupyter Notebooks and async frameworks (like FastAPI) already have an active event loop. Using background=True safely isolates the agent from this loop:
async def main():
agent.start(background=True) # Spawns isolated thread, safe for async context
# ... your async app logic ...
agent.stop() # Cleanly shuts down the thread without blocking your loop permanently
Always use agent.stop() to ensure that the agent unregisters from the registry and releases its network ports cleanly. In a standard script, agent.start(background=False) handles KeyboardInterrupt gracefully out of the box.
Transport Management
Agent.transport
transport: Transport | NoneRead or replace the communication transport used for both outbound client calls and inbound server routes.
Setter value
transportTransportType | TransportrequiredA registered transport alias or configured transport instance. Setting an alias creates a transport from
card.url. Setting a value rebuilds the associatedAgentClientandAgentServer, then updates the card's transport and streaming capability metadata. Although the setter annotation includesNone, the implementation rejects it withValueError; construct the Agent without a transport instead of assigningNone.
Reassigning this property configures objects and routes but does not start the new server. Avoid swapping transports while an Agent is serving; stop it first so connection ownership and open ports remain deterministic.
Agent.a2a
a2a: boolReport whether the optional A2A 1.0 compatibility boundary was enabled at construction. This is a configuration flag, not a live peer-capability probe.
Agent.client
client: AgentClient | NoneReturn the outbound client built around the current transport. It is None when no transport is configured; high-level methods such as call_agent() raise rather than silently ignoring that condition.
Agent.server
server: AgentServer | NoneReturn the inbound server facade created for the current transport, or None when that transport has no server implementation. Runtime and network transports can expose the same logical endpoint specifications through different backends.
Task and Message Handling
Core Task Processing
Agent.run_task
async run_task(task: Task) -> TaskRun the configured handle_task() implementation inside the live-execution registry. Server routes use this wrapper so even a completely overridden handler remains discoverable through active_task_ids and cancellable by task ID.
Parameters
taskTaskrequired- The mutable protocol task to execute. Terminal tasks are returned immediately without registering a new execution.
Returns
taskTask- The handler result. Successful, failed, and canceled snapshots are offered to
run_storewhen configured.
cancel_task() is converted into a returned task in the canceled state. External coroutine cancellation is also persisted as canceled but asyncio.CancelledError is re-raised to its caller.Agent.run_task_streaming
run_task_streaming(task: Task) -> AsyncIterator[Any]Stream the configured handler under the same active-task registration used by non-streaming server routes. This outer wrapper gives custom streaming handlers the runtime's cancellation and snapshot guarantees.
Parameters
taskTaskrequired- Task whose status and outputs are streamed. A terminal task yields one final status event and stops.
Yields
eventAny- Typed task status, progress, LLM, artifact, or error events produced by
handle_task_streaming().
TaskStatusUpdateEvent whose state is canceled and whose metadata includes the serialized task and reason.Agent.handle_task
async handle_task(task: Task) -> TaskProvide the default task-handler boundary. It normalizes a RunContext, emits best-effort telemetry start/end hooks, and delegates deterministic execution to execute_task(). Telemetry failures are logged once per hook and cannot alter the task outcome. Override this method for application-specific routing or orchestration, not merely to add a tool or LLM.
Parameters
taskTaskrequired- Task to process. Only explicit executable parts are acted upon; ordinary native text does not implicitly trigger inference.
Returns
taskTask- Updated task returned by the execution engine or custom handler.
run_task(). Direct callers of a fully custom handler should use that wrapper too, or call execute_task() inside the override to retain the standard engine.Agent.handle_task_streaming
handle_task_streaming(task: Task) -> AsyncIterator[Any]Execute a task while emitting its lifecycle as typed events. The default stream begins with a working-state transition, relays tool and LLM progress, emits artifact updates, and finishes with a terminal status containing the complete task.
Parameters
taskTaskrequired- Task to mutate as streamed work completes.
Yields
eventTaskStatusUpdateEvent | TaskProgressEvent | TaskLLMStreamEvent | TaskArtifactUpdateEvent | TaskErrorEvent- Provider-neutral events suitable for SSE, WebSocket, runtime, gRPC, or direct consumers.
TaskErrorEvent followed by a final failed status event. Consumers should use the final status metadata as the authoritative task snapshot.tool_result and agent_call_result events, client-visible TaskLLMStreamEvent.metadata retains correlation fields and sets result_omitted=true but does not carry the internal result. Ordinary tool observations can remain in private LLM history and configured telemetry. Generated knowledge-tool passages are stricter: raw evidence is available only to the active model loop, then replaced in persistent history and observability with an omission receipt and bounded search statistics.Agent.execute_task
async execute_task(task: Task) -> TaskExecute one deterministic step from the most recently appended message or artifact. tool_call parts invoke registered tools, infer parts enter the LLM loop, and authenticated inbound A2A text is translated to inference by the default engine. Other part types are left inert.
Parameters
taskTaskrequired- The same mutable Task instance receives outputs, artifacts, state transitions, and normalized run-context metadata.
Returns
taskTask- The input object after execution. Success completes it, error output fails it, and an explicit input-required status pauses it.
action_result receipt is retained because it contains the observation of a completed side effect.BudgetEnforcer is shared by explicit tool parts and every iteration or retry inside all infer parts. Inline nested tasks use their own scope and restore the parent budget afterward.run_store after each completed top-level part. Successful tools or delegations selected inside LLM.infer() additionally create and immediately snapshot an Artifact(kind="action_result") JSON receipt with completion status, action ID, and source/kind/step metadata. Internal results are deliberately omitted from this client-visible artifact. Ordinary tool observations remain private to model history; generated knowledge evidence is also scrubbed from persistent history and telemetry after the active loop. Later failure, cancellation, or budget exhaustion therefore preserves evidence of completed side effects without exposing internal result data.Agent.compact_history
async compact_history(
request: HistoryCompactionRequest | dict[str, Any] | None = None,
) -> HistoryCompactionResultCompact the Agent LLM's current or persisted session history through the control plane. The operation is deliberately outside task inference, so it is never advertised to the model and consumes no inference step.
Parameters
requestHistoryCompactionRequest | dict[str, Any] | Nonedefault: None- Compaction strategy, limits, and optional session ID.
NoneusesHistoryCompactionRequestdefaults. Dictionaries are validated throughfrom_dict().
Returns
resultHistoryCompactionResult- Before/after counts, estimated tokens, strategy, and summary metadata.
Raises
RuntimeError- The Agent has no LLM.
TypeError- The request or an authorized replacement payload has the wrong shape.
policy error- The
llm.history.compactcapability is denied or requires unavailable approval.
Agent.describe_state
async describe_state(
request: str | StateOperationRequest | dict[str, Any] | None = None,
*,
session_id: str | None = None,
stores: tuple[str, ...] | list[str] | None = None,
include_data: bool | None = None,
) -> StateOperationResultInspect enabled persistent-state stores without mutating them. A string request is shorthand for a session ID; explicit keyword arguments override values supplied in a request object or dictionary.
Parameters
requeststr | StateOperationRequest | dict[str, Any] | Nonedefault: None- Optional normalized state-operation request or session-ID shorthand.
session_idstr | Nonedefault: None- Limit session-aware reports, especially conversation history, to one logical session.
storestuple[str, ...] | list[str] | Nonedefault: None- Store names to report.
Noneasks the State implementation for its normal scope. include_databool | Nonedefault: None- Whether reports may include stored data in addition to existence and counts; omitted preserves any request value.
Returns
resultStateOperationResult- A structured per-store report, including disabled or missing stores rather than hiding them.
state.describe. Although read-only, policy may still redact, replace, approve, or deny the concrete request.Agent.reset_state
async reset_state(
request: str | StateOperationRequest | dict[str, Any] | None = None,
*,
session_id: str | None = None,
stores: tuple[str, ...] | list[str] | None = None,
) -> StateOperationResultClear persistent Agent state through an authorized control-plane action. Supplying a session ID precisely clears that conversation session; omitting it performs a namespace-level reset of all enabled stores.
Parameters
requeststr | StateOperationRequest | dict[str, Any] | Nonedefault: None- Request object, dictionary, session-ID shorthand, or default full-reset request.
session_idstr | Nonedefault: None- Conversation session to clear. This is the safer, narrower form for user-facing reset controls.
storestuple[str, ...] | list[str] | Nonedefault: None- Requested store selection. Partial namespace-wide resets may be rejected because the current storage abstraction resets by namespace.
Returns
resultStateOperationResult- Structured reset, missing-store, and error reports.
state.reset authorization and mutates persisted data. Use a session_id whenever the intent is to forget one conversation rather than the Agent namespace.Agent.compact_state
async compact_state(
request: str | StateOperationRequest | dict[str, Any] | None = None,
*,
session_id: str | None = None,
strategy: HistoryCompactionStrategy | None = None,
max_messages: int | None = None,
max_tokens: int | None = None,
preserve_recent: int | None = None,
summary_max_tokens: int | None = None,
) -> StateOperationResultLoad one persisted conversation, compact it with the LLM-owned history compactor, save the replacement history, and return before/after state metadata.
Parameters
requeststr | StateOperationRequest | dict[str, Any] | Nonedefault: None- Base state-operation request or session shorthand.
session_idstr | Nonedefault: None- Required logical conversation session. If absent, the method returns a structured error result rather than raising.
strategyHistoryCompactionStrategy | Nonedefault: None- Recent-message, token-budget, or summary strategy.
Nonepreserves the request/default strategy. max_messagesint | Nonedefault: None- Maximum retained messages for message-count compaction.
max_tokensint | Nonedefault: None- Estimated history-token budget for token compaction.
preserve_recentint | Nonedefault: None- Recent messages protected when older context is summarized.
summary_max_tokensint | Nonedefault: None- Maximum tokens requested for the generated summary.
Returns
resultStateOperationResult- A report showing whether conversation state existed, whether it was compacted, and nested compaction counts.
result.errors; policy denial still raises through the authorization layer.Agent.cancel_task
async cancel_task(
request: str | TaskCancellationRequest,
reason: str | None = None,
) -> TaskRequest best-effort cancellation of work currently registered on this process. The runtime marks both Task and RunContext cancellation state, signals the cooperative token, and interrupts the owning coroutine at its next await point.
Parameters
requeststr | TaskCancellationRequestrequired- Active task ID or typed request. A typed request can carry its own reason.
reasonstr | Nonedefault: None- Human-readable reason. When supplied alongside a typed request, this explicit value takes precedence.
Returns
taskTask- The active task after transition to
canceled.
Raises
TaskNotFoundError- No active execution with that ID exists on this Agent.
TaskNotCancelableError- The task has already reached a terminal state.
raise_if_cancelled() at safe checkpoints.Agent.get_cancellation_token
get_cancellation_token(task_id: str) -> CancellationToken | NoneReturn the process-local cooperative token for an active task.
Parameters
task_idstrrequired- Task ID currently visible in
active_task_ids.
Returns
tokenCancellationToken | None- Live token, or
Noneafter completion or before registration. Tokens are never serialized into protocol objects.
Agent.active_task_ids
active_task_ids: tuple[str, ...]Return an immutable snapshot of task IDs currently registered for live execution. Completed tasks disappear immediately; query a configured run store for historical snapshots.
Agent.invoke
async invoke(
message: str,
part_type: Literal["tool_call", "infer"] = "infer",
tool_name: str | None = None,
tool_args: dict[str, Any] | None = None,
session_id: str = "invocation_session_id",
) -> strCreate a one-step task, process it through handle_task(), and return only the final part content. This convenience API is useful for direct application calls but intentionally discards the richer Task envelope.
Parameters
messagestrrequired- User prompt for inference. In tool-call mode it is not used as the tool argument payload.
part_typeLiteral["tool_call", "infer"]default: "infer"- Choose an LLM inference part or an explicit registered-tool call.
tool_namestr | Nonedefault: None- Registered tool name in tool-call mode. Omission becomes an empty name and therefore produces a normal tool-not-found output.
tool_argsdict[str, Any] | Nonedefault: None- Keyword arguments encoded into the tool-call part.
session_idstrdefault: "invocation_session_id"- Conversation-state partition attached to task metadata. The stable default means sequential invocations share history when conversation state is enabled.
Returns
responsestr- Last part content, or
"No response generated"when the task produced none.
Raises
ValueError- An unsupported
part_typewas supplied.
handle_task(), run_task(), or the client API when callers need task state, artifacts, run context, or structured error information.Agent.ask
async ask(
question: str,
*,
knowledge: str | list[str] | tuple[str, ...] | None = None,
k: int | None = None,
where: dict[str, Any] | None = None,
citations: bool = True,
session_id: str = "ask_session_id",
) -> RAGAnswerRun deterministic retrieve-then-answer through the Agent's normal task,
policy, cancellation, telemetry, history, budget, and inference boundaries.
Unlike invoke() in automatic mode, this method always searches
before the first model call.
Parameters
questionstrrequired- Non-empty user question used as both the retrieval query and original inference request.
knowledgestr | list[str] | tuple[str, ...] | Nonedefault: None- Attached knowledge name or names. Omission searches every attached source.
kint | Nonedefault: None- Maximum hits per selected source. Omission uses each source's
default_k. wheredict[str, Any] | Nonedefault: None- Metadata filter passed to every selected source.
citationsbooldefault: True- Request bracketed evidence labels and retain structured Citation values. When false, hits are still returned but
RAGAnswer.citationsis empty. session_idstrdefault: "ask_session_id"- Conversation-state partition attached to the generated task.
Returns
answerRAGAnswer- Final model text together with the original query, normalized hits, and optional structured citations.
Raises
RuntimeError- No knowledge source is attached.
ValueError | TypeError- The question, selected names, result count, or filters are invalid.
retrieval or inference error- Search, policy, budget, provider, and task failures propagate through their normal typed errors.
Task Lifecycle
The default Agent implementation manages Task.state for you:
- Incoming non-terminal tasks move to
TaskState.WORKING. - Successful tool or LLM outputs move the task to
TaskState.COMPLETED. - Error parts, failed tool outputs, or raised exceptions move the task to
TaskState.FAILED. - Status parts requesting more input move the task to
TaskState.INPUT_REQUIRED.
Every successful state change is appended to task.metadata["state_history"]. Streaming handlers emit matching TaskStatusUpdateEvent events and include the final serialized task in the final status event metadata.
Before execution, the default runtime also normalizes RunContext into task.metadata["run_context"]. This gives applications one typed place for session IDs, trace IDs, workspace URIs, permission metadata, budgets, cancellation state, and parent/child agent chains. See Runtime for the full context and event-sink API.
AgentServer routes call run_task() and run_task_streaming(), so fully overridden handlers still receive active-task registration and remote cancellation. Direct application code should also use these wrappers when it invokes a fully custom handler. Prefer calling await self.execute_task(task) inside custom handlers when you only need to wrap or augment the default execution.
Live Task Cancellation
await agent.cancel_task(task.id, reason="Stopped by user") controls work that is currently active on this Agent. It is different from Task.cancel(): the task helper records serializable lifecycle state, while the Agent API also signals a live CancellationToken and cancels the owning asyncio.Task.
The default LLM, tool, streaming, and delegation paths already check this token. A custom CPU loop can retrieve it and add explicit checkpoints:
async def handle_task(self, task: Task) -> Task:
token = self.get_cancellation_token(task.id)
for item in application_items:
if token is not None:
token.raise_if_cancelled()
await process(item)
return task.complete("done")
Only active runs appear in active_task_ids. Wait until task acceptance or a first status event before canceling; completed entries are removed and belong in application storage. Cancellation is best-effort because synchronous functions and external systems may not stop immediately. See Runtime cancellation for the complete contract.
The Inference Loop Integration
When execute_task() encounters an infer part, it delegates to LLM.infer() with:
- The query: Extracted from the task's message content
- Knowledge retrieval: Deterministic pre-retrieval for
"always"or"required"(with knowledge tools suppressed afterward to avoid duplicate reads), while"auto"exposes each source as an ordinary search tool - The agent's tools: All registered tools passed as a dictionary
- Validated discovered Agents: Registry-advertised targets, excluding this Agent and names already in the ancestor chain
- An agent callback: Enables delegation only when at least one valid target was discovered
- Runtime controls: Policy authorization, cancellation, the normalized
RunContext, and the task-scoped budget enforcer - Optional stream observers: Used by
handle_task_streaming()to emittask_llm_streamevents while the finalinfer_outputpart is produced
# Simplified view of what happens inside execute_task()
result = await self.llm.infer(
query=query,
tools=self.tools,
agent_callback=dispatch_discovered_agent,
cancellation_token=token,
run_context=context,
budget_enforcer=task_budget,
)
Registry discovery is a best-effort prompt affordance. If discovery fails, local LLM and tool work continues without delegation targets. Existing custom LLM.infer() overrides keep working: Agent supplies budget_enforcer only when the override explicitly accepts it or **kwargs.
The agent callback (_handle_agent_call) is invoked when the LLM produces an agent_call action. It:
- Requires a Registry-advertised name and rejects a model-produced URL
- Rejects self-delegation and ancestor-cycle targets before dispatch
- Resolves the agent name to URL by querying the registry
- Creates a child Task and RunContext with the appropriate infer or tool-call part
- Sends the task to the target agent via
call_agent() - Validates the remote Task state and returns output only from a genuinely
completedtask. Item IDs distinguish new output from the outbound request, supporting both full-task and response-only transport shapes. Remotefailed,canceled, non-terminal, empty-completion, andinput-requiredresponses are propagated explicitly instead of echoing the child request as a successful result.
This enables a coordinator agent to delegate work to specialized agents without manual orchestration.
User Query → Coordinator Agent → LLM.infer()
↓
agent_call action
↓
_handle_agent_call()
↓
resolve agent URL
↓
call_agent(weather_agent)
↓
Weather Agent processes task
↓
Result returned to LLM
↓
LLM produces final response
Controlling Agent Delegation
By default, any agent with an LLM can dynamically delegate work to other agents discovered via the registry. However, you can explicitly disable or control delegation using the agent's Capabilities:
delegation: A boolean flag (defaulting toTrue) indicating whether the agent is allowed to delegate tasks to other agents.has_llm: A boolean flag (defaulting toFalse) showing whether the agent has an LLM as a core component.
Disabling Delegation
If you set "delegation": False within the agent's card capabilities:
- The agent will not query the registry to discover other agents.
- The agent's prompt builder will not inject other agents' definitions or descriptions into the LLM system instructions.
- The inference engine's
agent_callbackis set toNone, completely preventing any remote task delegation loops.
Configuration Example
To disable delegation, simply define it in your AgentCard capabilities dictionary:
from protolink.agents import Agent
writer = Agent(
card={
"name": "writer",
"url": "http://localhost:8051",
"description": "Writes drafts and decides routes.",
"capabilities": {
"delegation": False # Disables A2A delegation completely
}
},
llm=llm,
)
Communication Methods
Agent.call_agent
async call_agent(
agent_url: str,
task: Task,
*,
protocol: Literal["auto", "protolink", "a2a"] = "auto",
) -> TaskSend a complete Task to a peer through this Agent's configured client. Before dispatch, ProtoLink ensures that the task carries a RunContext and extends its agent chain for trace and delegation correlation.
Parameters
agent_urlstrrequired- Reachable peer URL or runtime URI.
taskTaskrequired- Mutable Task envelope to send. Transport serialization does not strip its native metadata when ProtoLink protocol is used.
protocolLiteral["auto", "protolink", "a2a"]default: "auto""auto"preserves the richer native contract and discovers A2A-only peers when A2A is enabled. The other values force one boundary.
Returns
taskTask- Peer response task with its updated lifecycle and outputs.
Raises
RuntimeError- This Agent has no configured transport/client.
transport or protocol error- Connection, authentication, translation, and peer errors propagate from
AgentClient.
Agent.send_message_to
async send_message_to(
agent_url: str,
message: Message,
*,
protocol: Literal["auto", "protolink", "a2a"] = "auto",
) -> MessageSend a standalone Message to a peer and return its response message. Use call_agent() when lifecycle state, artifacts, metadata, cancellation, or structured-flow context matters.
Parameters
agent_urlstrrequired- Reachable peer address.
messageMessagerequired- Role and parts to send.
protocolLiteral["auto", "protolink", "a2a"]default: "auto"- Same protocol-selection rules as
call_agent().
Returns
messageMessage- Response decoded from the peer.
Raises
RuntimeError- No transport/client is configured.
agent = Agent(card=card, transport="http", a2a=True)
# Preserve the richer native contract when the peer supports it; otherwise use A2A.
result = await agent.call_agent(peer_url, task, protocol="auto")
# Select the protocol explicitly when the peer protocol is known.
result = await agent.call_agent(peer_url, task, protocol="a2a")
Agent-originated A2A calls require the advertised JSON-RPC interface to share the discovered Agent Card's origin. This secure policy is intentionally fixed on the compact Agent facade. Applications that explicitly trust a split-origin deployment can construct an AgentClient(..., a2a_allow_cross_origin=True) for that outbound integration.
At the A2A boundary, a standard user text part remains a ProtoLink Part(type="text") for custom handlers. The default Agent engine recognizes task.metadata["a2a_inbound"] and treats that text as an inference request when an LLM is configured. A ProtoLink infer prompt becomes standard A2A user text outbound. Standard text, data, file, and URI content can be translated. ProtoLink-specific tool_call parts, structured-flow state, runtime context, and native control endpoints are not portable A2A contracts; keep protocol="protolink" when a peer needs those details.
Synchronous API (SyncAgent)
Protolink is built on an asynchronous foundation using asyncio, which is essential for handling concurrent agent interactions and streaming responses. However, many development workflows, such as data science notebooks, CLI tools, and simple automation scripts, benefit from a straightforward, blocking API.
The Agent class provides a .sync property, which is an instance of SyncAgent. This class acts as a thin, synchronous wrapper around the agent's core async methods.
Why Use the Sync API?
- Reduced Boilerplate: Eliminates the need for
async/awaitand event loop management in scripts. - Environment Compatibility: Works seamlessly in standard Python environments and legacy codebases that are not yet async-ready.
- Prototyping: Allows for faster iteration when building simple "input-output" agent flows.
How it Works Internally
The SyncAgent class does not re-implement any logic. Instead, it delegates calls to the agent's async methods using asyncio.run(). This ensures that all behavior, including tool execution, state management, and LLM orchestration, remains identical across both APIs.
The synchronous API is not thread-safe if called from within an active event loop (e.g., inside a FastAPI endpoint or an async function). Doing so will raise a RuntimeError. For async applications, always use the standard await agent.invoke() methods.
Key Sync Methods
The sync facade exposes blocking equivalents with the same parameter and return contracts:
SyncAgent.invoke
invoke(
message: str,
part_type: Literal["tool_call", "infer"] = "infer",
tool_name: str | None = None,
tool_args: dict[str, Any] | None = None,
session_id: str = "invocation_session_id",
) -> strBlocking form of Agent.invoke(). Every argument and the returned final-part text have the same meaning; the wrapper runs the coroutine with asyncio.run().
Parameters
messagestrrequired- User prompt used to create an infer Part. In tool-call mode the wrapper still forwards it, but the underlying Agent builds the tool Part from
tool_nameandtool_args. part_typeLiteral["tool_call", "infer"]default: "infer"- Select direct inference or an explicit tool call. Other values are rejected by
Agent.invoke(). tool_namestr | Nonedefault: None- Registered tool name for tool-call mode. A falsey value becomes an empty tool name and produces the Agent's normal tool-not-found result.
tool_argsdict[str, Any] | Nonedefault: None- Keyword arguments placed in the generated tool-call Part.
Noneand an empty mapping are normalized to an empty argument mapping. session_idstrdefault: "invocation_session_id"- Session identifier written to task metadata before execution. The stable default shares conversation state across sequential invocations when conversation persistence is enabled.
SyncAgent.ask
ask(
question: str,
*,
knowledge: str | list[str] | tuple[str, ...] | None = None,
k: int | None = None,
where: dict[str, Any] | None = None,
citations: bool = True,
session_id: str = "ask_session_id",
) -> RAGAnswerBlocking form of Agent.ask() with the same retrieval, filter,
citation, and return contract. The wrapper uses asyncio.run() and
must not be called from an active event loop.
SyncAgent.discover_agents
discover_agents(
filter_by: dict[str, Any] | None = None,
) -> list[AgentCard]Blocking registry discovery with the Agent's normal TTL cache and empty-list behavior when no registry is configured.
Parameters
filter_bydict[str, Any] | Nonedefault: None- Optional nested card-field criteria forwarded to registry discovery.
SyncAgent.call_agent
call_agent(
agent_url: str,
task: Task,
*,
protocol: Literal["auto", "protolink", "a2a"] = "auto",
) -> TaskBlocking form of Agent.call_agent(), including context propagation and protocol selection.
Parameters
agent_urlstrrequired- Reachable peer URL or runtime URI forwarded unchanged to the Agent client.
taskTaskrequired- Mutable task envelope sent to the peer after the Agent ensures its run context.
protocolLiteral["auto", "protolink", "a2a"]default: "auto"- Keyword-only protocol selector.
"auto"prefers the native ProtoLink contract and discovers an A2A-only boundary when configured; the other values force one protocol.
SyncAgent.cancel_task
cancel_task(
task_id: str,
reason: str | None = None,
) -> TaskBlocking form of local live-task cancellation.
Parameters
task_idstrrequired- ID of an execution currently registered on the wrapped Agent. Unlike the async method, this facade does not accept a
TaskCancellationRequest. reasonstr | Nonedefault: None- Optional human-readable cancellation reason propagated to the task state and serialized RunContext.
Usage Example
from protolink.agents import Agent
from protolink.models import Task
agent = Agent(
card={"name": "my-agent", "description": "Runtime demo agent", "url": "runtime://agent"},
transport="runtime",
)
# Use the .sync property for blocking calls
response = agent.sync.invoke("Hello, agent!")
print(f"Agent said: {response}")
# Discovering other agents synchronously
discovered = agent.sync.discover_agents(filter_by={"name": "weather-agent"})
if discovered:
target_url = discovered[0].url
# Call agent synchronously
task = Task.create_infer("What is the temperature?")
result = agent.sync.call_agent(target_url, task)
Skills Management
Skills represent the capabilities that an agent can perform. Skills are stored in the AgentCard and can be automatically detected or added.
Skills Modes
| Mode | Description |
|---|---|
"auto" | Automatically detects skills from tools and public methods, and adds them to the AgentCard |
"fixed" | Uses only the skills explicitly defined in the AgentCard |
Skill Detection
When using "auto" mode, the agent detects skills from:
- Tools: Each registered tool becomes a skill when it is added.
- Card declarations: Existing
AgentCard.skillsare retained.
The internal detector can describe public methods, but the public Agent(..., skills="auto") path intentionally calls it with public-method detection disabled. This keeps infrastructure methods such as lifecycle and registry controls out of the advertised skill list.
# Auto-detect skills from tools only
agent = Agent(card, skills="auto")
# Use only skills defined in AgentCard
agent = Agent(card, skills="fixed")
Skills in AgentCard
Skills are persisted in the AgentCard and serialized when the card is exported to JSON:
from protolink.models import AgentCard, AgentSkill
# Create skills manually in AgentCard
card = AgentCard(
name="weather_agent",
description="Weather information agent",
skills=[
AgentSkill(
id="get_weather",
description="Get current weather for a location",
tags=["weather", "forecast"],
examples=["What's the weather in New York?"]
)
]
)
# Use fixed mode to only use these skills
agent = Agent(card, skills="fixed")
Knowledge Management
Knowledge sources are specialized read-only tools. Attaching one keeps
agent.knowledge, agent.tools, advertised skills, and Agent Card capability
metadata synchronized. See the complete
Retrieval-Augmented Generation guide for ingestion and retrieval.
Agent.add_knowledge
add_knowledge(
knowledge: Knowledge | Retriever,
) -> KnowledgeAttach one knowledge source and register its generated
search_<name> tool.
Parameters
knowledgeKnowledge | Retrieverrequired- A configured facade or structural retriever. A plain retriever is wrapped with the default name
"knowledge".
Returns
knowledgeKnowledge- The normalized attached facade.
Raises
ValueError- The knowledge name is already attached or its generated tool name conflicts with an existing tool.
card.capabilities.rag and card.capabilities.tool_calling to true.Agent.retriever
retriever(
*,
name: str = "knowledge",
description: str | None = None,
default_k: int = 5,
)Adapt and attach a synchronous or asynchronous application search function as
retrieval-only knowledge. The callable receives query and, when
its signature accepts them, k and where.
Tool Management
Tools give agents explicit callable capabilities. ProtoLink supports opt-in built-ins, native Python functions, custom BaseTool implementations, and MCP adapters.
Agent.add_tool
add_tool(tool: BaseTool) -> NoneRegister or replace a runtime tool by name and synchronize its public skill advertisement.
Parameters
toolBaseToolrequired- Executable tool carrying a stable name, description, schemas, tags, and examples. Replacing an existing runtime tool also replaces its generated skill; an independently card-defined skill with the same ID is preserved on the first registration.
agent.tools and card.skills. Policy, validation, approvals, telemetry, and cancellation run only when the tool is called.Agent.tool
tool(
name: str,
description: str,
input_schema: dict[str, Any] | None = None,
output_schema: dict[str, Any] | None = None,
tags: list[str] | None = None,
examples: list[Any] | None = None,
capabilities: list[str] | tuple[str, ...] | set[str] | None = None,
action_builder: ActionBuilder | None = None,
)Wrap a Python callable as a ProtoLink Tool, register it immediately, and return the original callable so ordinary direct Python usage remains possible.
Parameters
namestrrequired- Stable identifier exposed to models, peers, policy, and serialized configuration.
descriptionstrrequired- Purpose statement used in prompts and skill discovery.
input_schemadict[str, Any] | Nonedefault: None- Optional JSON Schema used to validate keyword arguments before authorization.
output_schemadict[str, Any] | Nonedefault: None- Descriptive return schema advertised with the tool.
tagslist[str] | Nonedefault: None- Discovery and presentation labels.
exampleslist[Any] | Nonedefault: None- Representative invocations copied to the generated Agent skill.
capabilitieslist[str] | tuple[str, ...] | set[str] | Nonedefault: None- Permission capabilities that policy must authorize immediately before execution.
action_builderActionBuilder | Nonedefault: None- Hook that can enrich the concrete
RunActionwith preview artifacts or metadata before approval.
Returns
decoratorCallable- Decorator that registers the wrapped function and returns that same function.
Agent.call_tool
async call_tool(
tool_name: str,
**kwargs,
) -> AnyValidate, authorize, and execute a registered tool with a fresh RunContext associated with this Agent.
Parameters
tool_namestrrequired- Registered key in
agent.tools. **kwargsAny- Arguments validated against the tool schema, then passed to its callable after policy authorization.
Returns
resultAny- Raw tool result; unlike task execution, this method does not wrap success or failure in a
tool_outputPart.
Raises
ValueError- The named tool is not registered.
validation, policy, approval, or tool error- Direct calls propagate these errors to the caller.
Agent.call_tool_in_context
async call_tool_in_context(
tool_name: str,
context: RunContext,
**kwargs: Any,
) -> AnyExecute a tool while preserving an application-supplied RunContext. Use this form in deterministic flows and custom runtimes so permissions, trace IDs, budgets, workspace metadata, and cancellation state participate in authorization.
Parameters
tool_namestrrequired- Registered key in
agent.tools. An unknown name raisesValueErrorbefore authorization. contextRunContextrequired- Existing typed run context supplied unchanged to tool-action preparation and policy authorization.
**kwargsAny- Tool keyword arguments. The runtime validates supported schemas, authorizes the prepared action, and invokes the tool with the authorized argument mapping, which may differ from the original mapping.
Agent.authorize_action
async authorize_action(
action: RunAction,
context: RunContext | None = None,
) -> ActionAuthorizationEvaluate a fully prepared runtime action without executing its side effect. Custom orchestration can use the same policy and approval checkpoint as built-in tools.
Parameters
actionRunActionrequired- Concrete operation, payload, capabilities, description, and preview artifacts to evaluate.
contextRunContext | Nonedefault: None- Active run context; omission creates a fresh context containing this Agent in its chain.
Returns
authorizationActionAuthorization- Approved action, potentially with policy- or approver-modified payload.
authorization.action, not an earlier copy of the action. Policies and approvers may narrow or replace arguments during authorization.# Using the decorator approach
@agent.tool("calculate", "Performs basic calculations")
def calculate(operation: str, a: float, b: float) -> float:
if operation == "add":
return a + b
elif operation == "multiply":
return a * b
else:
raise ValueError(f"Unsupported operation: {operation}")
# Direct registration of built-in Tool instances
from protolink.tools import current_datetime, web_search
agent.add_tool(current_datetime())
agent.add_tool(web_search()) # Brave by default; calls may select engine="duckduckgo".
Built-ins are never enabled automatically. Registered built-ins follow the same validation, policy, telemetry, cancellation, and skill-advertising path as native tools. See Tools for the complete built-in API and network-safety contract.
Registry & Discovery
Agent.discover_agents
async discover_agents(
filter_by: dict[str, Any] | None = None,
) -> list[AgentCard]Query the configured registry for matching cards. Results are cached per stringified filter only when discovery_ttl is greater than zero.
Parameters
filter_bydict[str, Any] | Nonedefault: None- Optional registry-side card criteria, including nested fields such as
{"capabilities.streaming": true}.
Returns
cardslist[AgentCard]- Matching cards, or an empty list when no registry client is configured.
Agent.register / Agent.unregister
async register() -> None
async unregister() -> NoneRegister this Agent's current card or remove its URL from the configured registry. Both methods return silently when no registry client exists. Manual register() does not start the automatic heartbeat loop; lifecycle startup owns heartbeat scheduling.
Utility Methods
Agent.get_agent_card
get_agent_card(*, as_json: bool = True) -> AgentCard | dict[str, Any]Return the live identity card or its serializable dictionary representation. as_json=True returns a dictionary despite the historical parameter name; it does not return a JSON string.
Parameters
as_jsonbooldefault: True- Keyword-only representation switch. True calls
card.to_dict(); false returns the Agent's liveAgentCardobject rather than a defensive copy.
Agent.get_status / Agent.get_chat
get_status(
output_format: Literal["html", "json"] = "html",
) -> str
get_chat() -> strRender the built-in operational status or chat page. HTML mode and get_chat() return self-contained browser markup. Despite the "json" format name, the current get_status("json") implementation returns str(card.to_dict()), which is a Python dictionary representation rather than guaranteed valid JSON. The chat renderer displays a fallback when no LLM is configured, while POST chat handling requires an LLM and enabled exposure.
Parameters
output_formatLiteral["html", "json"]default: "html"- Format used only by
get_status(). HTML renders the operational page; JSON returns the string form of the card dictionary. Any other runtime value raisesValueError.get_chat()takes no arguments.
Agent.handle_chat_message
async handle_chat_message(
data: dict[str, Any],
) -> dict[str, str]Validate an incoming chat payload, invoke the Agent with its message and session ID, and return a response dictionary. The server route controls whether this handler is exposed.
Parameters
datadict[str, Any]required- Mapping containing
messageand optionallysession_id. A missing or falsey message returns an error mapping; the session defaults to"chat_default". The handler also returns error mappings when no LLM is configured, chat exposure is disabled, or invocation raises.
Agent.llm / Agent.storage
llm: LLM | None
storage: StorageThe llm setter calls validate_connection() and updates card.capabilities.has_llm from that result. The storage setter updates the existing State object's storage reference so future persistence follows the replacement backend; the annotation expects Storage, but the setter performs no runtime type check.
storage does not migrate data from the old backend. Changing llm does not rewrite existing conversation histories or rebuild a running server's route set.Agent.set_registry
set_registry(
registry: TransportType | Registry | RegistryClient | None,
registry_url: str | None = None,
) -> NoneReplace the Agent's discovery client. Passing None disables discovery; a Registry or RegistryClient is adopted; a transport alias constructs a client for registry_url.
Parameters
registryTransportType | Registry | RegistryClient | Nonerequired- Required selection value. A Registry contributes its client, a RegistryClient is retained directly, and a transport alias constructs a new client. A falsey value clears
registry_clientand logs an error; an unsupported truthy object also clears it. registry_urlstr | Nonedefault: None- Registry endpoint used only when
registryis a transport alias. If omitted for an alias, the method logs an error and returns without replacing the existing registry client.
register(), or start the Agent with lifecycle registration enabled.Agent.sync
sync: SyncAgentPer-instance blocking facade created during Agent construction. It is an ordinary attribute rather than a class property, and it delegates to this exact Agent instance.
Storage and Persistence
Protolink provides a storage abstraction to allow agents to persist data across tasks or even standalone.
Core Storage Interface
The Storage base class defines the CRUD interface:
from protolink.storage import Storage
class MyStorage(Storage):
def save(self, data): ...
def load(self): ...
def update(self, data): ...
def delete(self): ...
In-Memory Storage (Default)
Protolink includes a built-in InMemoryStorage which is the default storage backend for all agents. It is a lightweight, RAM-backed dictionary that supports TTL (Time-To-Live) for automatic cleanup.
from protolink.storage import InMemoryStorage
# Default: shared class-level store
storage = InMemoryStorage(namespace="my_agent", ttl=3600)
agent = Agent(card=card, storage=storage)
SQLite Storage
For persistent storage across restarts, use the built-in SQLiteStorage:
from protolink.storage import SQLiteStorage
storage = SQLiteStorage(db_path="my_agent.db", namespace="main_agent")
agent = Agent(card=card, storage=storage)
State Persistence
When an agent is initialized with the state parameter, it tracks internal state across multiple task executions based on a session_id.
-
Activation: Pass a list of state modules to the
Agentconstructor.# Enable conversation history and tool state persistenceagent = Agent(card=card, state=["conversation", "tools"]) -
Identification: Include a
session_idin your task metadata. This ID is used to partition the state in the storage.task = Task.create(Message.user("My name is Alice"))task.metadata["session_id"] = "user_123"await agent.execute_task(task) -
Resumption: Subsequent tasks with the same
session_idwill automatically load the previous state (e.g., conversation history) into the execution context.
Supported State Modules
| Module | Description |
|---|---|
conversation | Persists LLM conversation history between tasks with the same session_id. |
tools | Provides a storage-backed extension point for tool-specific state. |
task | Provides a storage-backed extension point for task metadata outside the live Task object. |
flow | Provides storage-backed flow context; active flow prompts are carried on task.flow_state. |
When using direct invocation methods like invoke() or sync.invoke(), a default session_id of "invocation_session_id" is used if none is provided. This ensures that sequential calls to the same agent instance share history by default when state=["conversation"] is enabled.
If no session_id is provided in the task metadata (for non-invoke calls), the agent falls back to using the task.id, effectively making that specific task stateless unless further responses are sent to it.
Chat Gateway
When an agent is configured with an LLM and uses an HTTP-compatible transport, Protolink automatically exposes a built-in Chat UI at the /chat endpoint. This provides a browser-based interface for interacting with the agent directly, ideal for development, demos, and quick testing.
How It Works
GET /chat- Serves a self-contained HTML/CSS/JS chat interface on HTTP-compatible transports. The endpoint is part of the agent route set and displays a fallback message if no LLM is configured or chat exposure is disabled.POST /chat- Accepts{"message": "...", "session_id": "..."}and returns{"response": "..."}. The endpoint is registered only when the agent has an LLM, and the handler returns an error if chat exposure is disabled.
The chat page displays agent metadata (name, description, skills) and LLM configuration (provider, model, temperature) in a sidebar, alongside a modern conversational interface.
Usage
No extra setup is needed, just provide an LLM when creating your agent:
from protolink.agents import Agent
from protolink.llms.api import OpenAILLM
agent = Agent(
card={"name": "assistant", "description": "A helpful assistant", "url": "http://localhost:8000"},
transport="http",
llm=OpenAILLM(model="gpt-4o"),
)
agent.start()
# Chat UI is now available at http://localhost:8000/chat
The /status page shows the agent's operational health and metadata. The /chat page provides an interactive conversation interface. Both are served automatically by HTTP-compatible agent transports when the agent starts.
YAML Import and Export
Protolink supports exporting an agent's configuration (identity card, capabilities, transport, TLS file references, LLM, security/authenticator, registered tools, and non-default first-party capability policy) to a YAML file, and importing it back to reconstruct a functional Agent instance. TLS serialization stores certificate paths and settings, never certificate or private-key contents.
Agent serialization methods
to_dict() -> dict[str, Any]
to_yaml_string() -> str
to_yaml(filepath: str) -> None
Agent.from_dict(data: dict[str, Any], **overrides) -> Agent
Agent.from_yaml_string(yaml_str: str, **overrides) -> Agent
Agent.from_yaml(filepath: str, **overrides) -> AgentExport the reconstructable Agent configuration as Python data or YAML, or create a new Agent from one of those representations. Import methods are class methods: subclasses receive an instance of the subclass.
Parameters
datadict[str, Any]requiredParsed Agent configuration for
from_dict().yaml_strstrrequiredYAML document for
from_yaml_string().filepathstrrequiredDestination for
to_yaml()or source forfrom_yaml().**overridesAnyConstructor values that replace serialized values during import. Use overrides for environment-specific transports, credentials, executable policies, approval handlers, or other dependencies that should not be trusted or embedded.
Configuration export is not a live runtime checkpoint. Active tasks, event loops, open connections, in-memory discovery caches, cancellation tokens, approval callbacks, and arbitrary executable policy objects are not serialized.
Exporting an Agent
To serialize and export an agent's configuration:
# Export to a YAML file
agent.to_yaml("agent_config.yaml")
# Get configuration as a YAML string
yaml_str = agent.to_yaml_string()
# Get configuration as a dictionary
config_dict = agent.to_dict()
Importing an Agent
To load and reconstruct an agent from a serialized configuration:
from protolink.agents import Agent
# Reconstruct from a YAML file
agent = Agent.from_yaml("agent_config.yaml")
# Reconstruct from a YAML string
agent = Agent.from_yaml_string(yaml_str)
# Reconstruct from a dictionary
agent = Agent.from_dict(config_dict)
Handling Dependencies and Overrides
- Security & Credentials: Treat exported Agent configuration as sensitive. Configured outbound
credentialsand authenticator settings can be serialized, including bearer secrets, API-key maps, basic credentials, and OAuth client secrets. Review and protect the file, or pass replacement values during import:agent = Agent.from_yaml("agent_config.yaml", credentials="my-secret-key")BRAVE_SEARCH_API_KEYis different:web_search()reads it from the environment only when the default Brave engine is invoked, so the built-in tool does not place that key in Agent dict/YAML output. The explicitengine="duckduckgo"path is keyless. - Built-ins & Policy: Built-in tools serialize by stable first-party identity. A non-default, exact
CapabilityPolicyserializes its declarative rules, default effect, and name. Executable custom policies (includingCapabilityPolicysubclasses) and approval callbacks are not embedded; pass them aspolicy=andapproval_handler=overrides when importing. An explicit policy override takes precedence over serialized first-party rules. - Tool Function Paths: Standard Python tools are serialized using their module and function name paths (e.g.
my_module:my_tool_func). When the agent is imported, Protolink dynamically imports the function. If the module cannot be imported (e.g., if loaded in a different environment), Protolink registers a stub tool that returns a clean runtime error when executed rather than crashing initialization. - MCP Tool Adapters: Model Context Protocol (MCP) tool configs are fully serialized. If the MCP dependencies are installed on the target machine, they will be initialized and bound correctly.
Abstract Methods
The Agent class provides a default implementation for handle_task that handles tool use and LLM inference automatically. You generally do not need to implement any abstract methods unless you require custom logic.
handle_task(task: Task) -> Task: Override this if you need custom task processing logic (e.g., conditional execution, routing).
from protolink.agents import Agent
from protolink.models import AgentCard, Task, Message
class EchoAgent(Agent):
async def handle_task(self, task: Task) -> Task:
last = task.get_last_part_content()
return task.complete(f"Echo: {last}")
Error Handling
The Agent class includes several error handling patterns:
- Missing Transport: Construction and
start()can operate without a server transport, but outboundcall_agent()andsend_message_to()raiseRuntimeError. - Authentication Failures: Returns
401or403responses for invalid auth. - Tool Errors: Direct
call_tool()calls propagate validation, policy, approval, and tool errors. Task-based tool execution converts ordinary tool failures into an error-bearingtool_outputpart; policy failures remain raised. - Task Processing: Non-streaming engine errors mark the task failed and are re-raised through direct handler calls. The streaming engine emits a
TaskErrorEventand a final failed status event.


