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.
Extensions versus middleware
| Interface | Responsibility | Example |
|---|---|---|
| Middleware | Transform data in one or more execution stages | Normalize a tool argument or shorten a tool result |
| Hook | Observe a lifecycle event | Emit a diagnostic metric after a tool finishes |
| Extension | Package capabilities, configuration, state, and lifecycle | Install search tools together with normalization and metrics |
Use standalone middleware when you only need a transformation. Use an extension when callers should enable a configured feature in one place, especially if it owns resources, has saved state, or combines multiple integration points.
An extension can use middleware. Inside install, call
ctx.add_middleware(MyMiddleware(...)). The middleware still runs in the same
engine pipeline as directly registered middleware. Being inside an extension
does not bypass permissions, change execution ordering rules, or make middleware
side effects durable transactions.
Built-in examples demonstrate this composition: LangSmithExtension installs
middleware and hooks for tracing; ResultSpillExtension combines result middleware,
a read_result tool, stored files, and cleanup.
A complete extension containing middleware
The following extension installs a fixture search tool, normalizes its queries, adds prompt context, and observes completions. No external search service is required to define the feature. The completion count is diagnostic, not an exactly-once audit ledger.
from dataclasses import replace
from typing import Any
from harnessx import (
Agent, Extension, ExtensionContext, HookContext, HookEvent,
Middleware, ToolCall,
)
class NormalizeSearch(Middleware):
async def before_tool_execution(self, call: ToolCall) -> ToolCall:
query = call.input.get("query")
if call.name == "search" and isinstance(query, str):
return replace(call, input={**call.input, "query": query.strip()})
return call
class SearchExtension(Extension):
name = "search_feature"
priority = 50
def __init__(self, collection: str) -> None:
self.collection = collection
self.completed_searches = 0
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.record_completion)
ctx.register_prompt_provider(
lambda: f"The search tool searches the {self.collection} collection."
)
def search(self, query: str) -> str:
"""Return a fixture result from the configured collection."""
return f"{self.collection}: {query}"
def record_completion(self, event: HookContext) -> None:
call = event.data["tool_call"]
if call.name == "search":
self.completed_searches += 1
async def on_save_session(self, ctx: ExtensionContext) -> dict[str, Any]:
return {"version": 1, "completed_searches": self.completed_searches}
async def on_load_session(self, ctx: ExtensionContext, state: dict[str, Any]) -> None:
count = state.get("completed_searches", 0)
if state.get("version") != 1 or type(count) is not int or count < 0:
raise ValueError("Invalid search extension state")
self.completed_searches = count
agent = Agent(extensions=[SearchExtension(collection="product_docs")])
# Run with `async with agent:` once your model provider is configured.
The application only selects SearchExtension; the extension takes care of wiring
its pieces. Tools registered without an explicit permission inherit the agent's
manager, normally ALLOW. safe is appropriate here because the fixture search has
no external effect; choose the replay policy for the real integration separately.
Use a fresh extension instance for each agent. Instances can contain mutable state, registrations, and clients; sharing one across agents is not a supported lifecycle shortcut. Constructor-defined subagents do not inherit parent extensions. Use a manual child wrapper when the child requires 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 | 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 prevent completion callbacks and teardown entirely.
Use async with Agent(...) as agent: to close normally. aclose() attempts all
extension teardowns and reports collected cleanup errors. Resource initialization
in on_turn_start should be lazy and idempotent: the same agent can handle multiple
turns, and durable recovery can repeat lifecycle work. An extension that owns an
async client should close it in teardown; an injected, caller-owned client should
remain caller-owned unless the API explicitly transfers ownership.
Installation rolls back tool, hook, middleware, and prompt registration mutations
when an install fails. It does not roll back arbitrary external effects, arbitrary
changes to the agent, or asynchronously acquired resources. Do not open those
resources in install; asynchronous teardown is not an installation rollback.
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 |
The four registration methods return disposable handles. handle.close() removes
the registration; closing twice is harmless. On agent shutdown, registrations made
through these methods are removed in reverse order for each extension. Registration
through ctx.agent.tools or other direct mutations does not receive this automatic
ownership tracking. Duplicate tools require an intentional replace=True; tracked
replacement restores the previous binding when removed if the extension still owns
the active binding.
ctx.register_tool supports name, description, input_schema, permission,
concurrent, replay_policy, timeout_seconds, and replace. Permission defaults
are the same as other registration paths. A tool installed by an extension is
subject to 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 model
request's system prompt. Their exceptions are currently ignored, so a prompt
provider is not a place for mandatory authorization or configuration checks.
The get_state/set_state metadata dictionary is shared; prefix keys with your
extension's name to avoid collisions. Save/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. Use a version field and validate on restoration, as in the example.
on_save_session is used for engine snapshots as well as explicit session saving;
it can run many times during a turn. on_load_session can run during recovery.
Neither callback is a one-time startup/shutdown signal. Reconstruct resources from
application configuration instead of persisting their Python objects.
When restoring an agent with Agent.load_session, supply the extension bindings
again through extensions=[...], along with provider/tool bindings where needed.
Required saved extension names must be present. Durable runtime factories similarly
need consistent, versioned bindings; snapshots do not serialize extension code.
Lifecycle callbacks and middleware can be repeated on recovery. External writes
inside a callback do not inherit tool execution keys, replay policy, or an
exactly-once guarantee. Put managed business effects into tools and enforce
required checks in the execution path. Hooks and on_turn_complete are best-effort
observation; their failure is not a reliable signal to stop an effect.
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 execute again to inspect a stored result.
This feature needs more than a result transform: it also needs a tool, files,
persistence integration, and cleanup, which is why it is an extension.
Spill files are temporary by default. Snapshot and durable artifact bindings preserve captured data beyond cleanup. A path in arbitrary tool text is not automatically a portable artifact, and a spill reference is not evidence of business validity or provenance. Distributed workers need shared artifact storage and explicit retention. Incident export controls optional artifact inclusion.
LangSmith tracing
LangSmithExtension combines hooks and middleware to produce connected agent,
model, and tool spans. It requires the optional LangSmith dependency and service
configuration for uploads. See examples/langsmith_tracing.py for the full
example, including a child agent with its own tracing extension. Tracing observes
execution; it does not replace durable recording or permission enforcement.
Each model span records what was sent: the system prompt, the messages, the tool
definitions, and the invocation parameters (model, max_tokens, temperature,
streaming), plus the prompt-cache prefix_key. On completion it records the
response, the stop reason, and usage with cache-read, cache-creation, and
reasoning token details, and it carries ls_model_name and ls_provider so
LangSmith can price the run.
Test an extension before reuse
Use a scripted provider to verify installation, transformations, and tool calls without a live model. Exercise successful and failed runs, cancellation, repeated turns, snapshot restoration, and cleanup. Assert that registered tools and middleware are removed when the extension closes and that an interrupted external effect is handled by the executor, not inferred from a hook counter.
See middleware and hooks for all transformation stages, and flight recording for incident export and artifact inclusion.