An extension is a reusable feature installed with Agent(extensions=[...]).
It can register tools, hooks, middleware, and dynamic prompt context, preserve
serializable state, and release resources when the agent closes.
Reach for an extension when callers should enable a configured feature in one place, especially when it owns resources, has saved state, or combines several integration points. For the choice between a standalone transformation and a packaged feature, see middleware and hooks.
An extension can use middleware. Inside install, call
ctx.add_middleware(MyMiddleware(...)). That middleware runs in the same engine
pipeline as directly registered middleware. It does not bypass permissions or change
ordering rules. The built-ins work this way: LangSmithExtension installs middleware
and hooks for tracing, and ResultSpillExtension combines result middleware, a
read_result tool, stored files, and cleanup.
A complete extension containing middleware
This extension installs a search tool, normalizes its queries, adds prompt context, and observes completions. The caller selects one extension and gets all four.
from dataclasses import replace
from harnessx import Agent, Extension, ExtensionContext, HookEvent, Middleware, ToolCall
class NormalizeSearch(Middleware):
async def before_tool_execution(self, call: ToolCall) -> ToolCall:
query = str(call.input.get("query", ""))
return replace(call, input={**call.input, "query": query.strip()})
class SearchExtension(Extension):
name = "search_feature"
def install(self, ctx: ExtensionContext) -> None:
ctx.register_tool(self.search, replay_policy="safe")
ctx.add_middleware(NormalizeSearch())
ctx.on_hook(HookEvent.TOOL_CALL_END, self.log_completion)
ctx.register_prompt_provider(lambda: "search covers the product docs.")
def search(self, query: str) -> str:
"""Search the product docs collection."""
return f"product_docs: {query}"
def log_completion(self, event) -> None:
print("tool finished:", event.data["tool_call"].name)
agent = Agent(extensions=[SearchExtension()])
Tools registered without an explicit permission inherit the agent's manager, normally
ALLOW. safe suits a search with no external effect; choose the replay policy for the
real integration separately.
Use a fresh extension instance for each agent. Instances hold mutable state, registrations, and clients, so do not share one across agents. Constructor-defined subagents do not inherit parent extensions. Use a manual child wrapper when the child needs its own extension configuration.
Lifecycle and ordering
Every extension needs a unique, nonempty name. priority is an integer, default
100; lower numbers install first, and equal priorities preserve input order.
Name collisions are rejected before installation.
| Method | When it runs | Contract |
|---|---|---|
install(ctx) |
Synchronously during agent construction, after skills and knowledge | Register capabilities; do not acquire async resources |
on_turn_start(ctx, user_message) |
At the start of a user turn | Async; may initialize required resources; errors fail the turn |
on_turn_end(ctx, final_text) |
On the successful finish path | Async; errors can fail the run |
on_turn_complete(ctx, result) |
Terminal completion, failure, or cancellation | Best-effort observer; receives RunResult |
on_save_session(ctx) |
When capturing execution/session state | Return JSON-compatible state; keep it fast and side-effect free |
on_load_session(ctx, state) |
When restoring captured state | Restore the feature's data; validate its version and shape |
teardown() |
During agent.aclose() |
Release extension-owned resources |
Start, finish, save, and load callbacks follow installation order. Terminal completion and teardown run in reverse installation order. Pauses and approval waits are not terminal completion. Hard process termination can skip completion callbacks and teardown entirely.
Use async with Agent(...) as agent: to close normally. aclose() attempts every
extension teardown and reports the cleanup errors it collected. Keep resource
initialization in on_turn_start lazy and idempotent: one agent can handle many
turns, and durable recovery can repeat lifecycle work. An extension that creates an
async client should close it in teardown. An injected, caller-owned client stays
caller-owned unless the API explicitly transfers ownership.
A failed install rolls back its tool, hook, middleware, and prompt registrations. It
does not roll back external effects or asynchronously acquired resources. Do not open
those resources in install.
ExtensionContext reference
Use the context's registration methods so the SDK tracks ownership:
| Method | Purpose |
|---|---|
ctx.register_tool(handler, ...) |
Register an inferred tool or pass input_schema explicitly |
ctx.add_middleware(middleware) |
Add a normal pipeline transformation |
ctx.on_hook(event, callback) |
Subscribe an observer |
ctx.register_prompt_provider(callback) |
Append dynamic text to the system prompt |
ctx.get_state(key, default) / ctx.set_state(key, value) |
Access extension metadata |
These four registration methods return disposable handles. handle.close() removes
the registration, and closing twice is harmless. On agent shutdown, each extension's
registrations are removed in reverse order. Registering through ctx.agent.tools or
other direct mutation gets no ownership tracking. Duplicate tools require an explicit
replace=True; a tracked replacement restores the previous binding when removed, as
long as the extension still owns the active binding.
ctx.register_tool supports name, description, input_schema, permission,
concurrent, replay_policy, timeout_seconds, retry, retry_if_result, and
replace. Permission defaults
match other registration paths, and an extension's tool passes the same engine checks
as any other tool.
Context properties include config, tools, hooks, middleware, memory,
session_id, and agent. The context is a convenience interface, not a security
sandbox: extensions are trusted Python code with access to the agent. Use ctx.agent
explicitly for advanced access; implicit forwarding of unknown context attributes is
deprecated.
Prompt providers are synchronous callbacks evaluated while building a request's system
prompt. Their exceptions are ignored, so put authorization and configuration checks
elsewhere. The get_state/set_state metadata dictionary is shared, so prefix keys
with your extension's name. Save and load callback output is stored under the
extension name and is the preferred contract for structured feature state.
Persistence and durable execution
Save state as JSON-compatible dictionaries, lists, strings, booleans, finite numbers, and null values. Do not serialize clients, locks, handlers, file objects, or credentials. Include a version field and validate the version and shape on restore.
on_save_session runs for engine snapshots as well as explicit session saving, so it
can run many times during a turn. on_load_session can run during recovery. Neither
is a one-time startup or shutdown signal. Rebuild resources from application
configuration rather than persisting their Python objects.
Put managed business effects into tools and enforce required checks in the execution
path. Lifecycle callbacks and middleware can repeat on recovery, and an external write
inside a callback gets no execution key or replay policy. Hooks and on_turn_complete
are best-effort observation.
Built-in extensions
Result spilling
from harnessx import Agent, ResultSpillExtension
agent = Agent(extensions=[ResultSpillExtension(threshold_bytes=32_000)])
Large tool results become compact envelopes while the extension stores their contents
and exposes read_result for slices, searches, and supported tabular operations. The
original tool need not run again to inspect a stored result. The feature needs a tool,
files, persistence integration, and cleanup, which is why it is an extension. See
flight recording for how spilled artifacts are retained and
exported.
LangSmith tracing
LangSmithExtension combines hooks and middleware to produce connected agent, model,
and tool spans. Model spans carry the request, the response, the stop reason, token
usage, and the ls_model_name and ls_provider fields LangSmith uses for pricing.
It requires the optional LangSmith dependency and service configuration for uploads.
See examples/07-quality/tracing_with_langsmith.py for the full example, with a span
for each model call and tool call.
See middleware and hooks for all transformation stages, and flight recording for incident export and artifact inclusion.