14 KiB
agent_harness/ package rules
agent_harness/ owns the decoupled agent harness for two agent shapes:
the tool-calling loop (core.agent.Agent via build_agent) and the
direct-answer path (stream_answer via the StreamAnswerFn seam in
ports.py, no tools). It orchestrates action tool-calling turns, three-path routing,
conversational answers, evidence gather, and headless execution. It was
extracted out of interactive_shell so the same harness can run the interactive
terminal and be invoked headlessly via agent_harness.turns.headless_dispatch.
Hard boundary (enforced by tests)
- No
import interactive_shellanywhere underagent_harness/. This is the whole point of the package and is checked bytests/core/agent/test_import_boundaries.py. The dependency direction is strictly one-way:interactive_shell -> agent_harness -> core. agent_harness/may depend oncore/,config/, andplatform/. It must not importintegrations/,tools/,surfaces/, orgateway/. Integration and tool behavior reaches the harness through ports inplatform/harness_ports.py, wired at startup viainstall_harness_ports()insurfaces/interactive_shell/ui/output/boundary.py(called frominstall_product_adapters()). It must not depend on terminal UI concerns (Rich rendering, prompt-toolkit mutable UI state, slash dispatch, the shellREGISTRY). The reusable session model, prompt history, grounding cache contracts, and task records live here;interactive_shellsupplies adapters and registry providers at runtime.
Layout
Top level holds the package's public surface: __init__.py (the curated
re-exports), ports.py, agent_builder.py, plus small shared helpers
(error_reporting.py, llm_resolution.py). Everything else lives in a
responsibility-scoped subpackage.
ports.py— Protocols the engine talks to (output, confirmation, session store, tool provider, prompt-context provider, telemetry, error reporter, evidence gatherer). Kept top-level as the central seam imported everywhere.agent_builder.py—AgentConfigdataclass +build_agent(config). The single instantiation site forcore.agent.Agentacross all surfaces (action, evidence, gateway). See "Agent construction pattern" below.turns/— the turn drivers that orchestratecore.agent.Agent:action_driver.py—run_action_agent_turn: one action tool-calling turn over the ports. Uses_build_action_agentfactory that returns anActionTurnPlan.orchestrator.py—run_turn: the three-path routing (summarize-observation / handled / gather+answer) and the conversational answer. Resolves integrations once at the top of the turn and stores them on the frozenturn_snapshot, soturn_snapshot.resolved_integrationsis the single source of truth for what the turn knows. Downstream components readturn_snapshot.resolved_integrations(e.g.action_driver._resolved_integrations_for_turnprefers it) rather than re-resolving. Do NOT reintroduce a per-component integration resolution whenturn_snapshotalready carries it.evidence_driver.py— bounded evidence-gather loop. Uses_build_evidence_agentfactory that returns anAgentConfighanded tobuild_agent.headless_dispatch.py— headless programmatic entry point (HeadlessAgent, constructed with the ports then.dispatch(message)per turn) plus in-memory port adapters for API / test runs.toolsis required — surfaces that want a text-only turn passNullToolProvider()explicitly.default_reasoning_client.py— production :class:~core.agent_harness.ports.ReasoningClientProviderdefault (lazyLLMRole.REASONINGclient).turn_snapshot.py,turn_results.py— neutral, surface-agnostic turn data shapes (immutable snapshot + facts-only result models).
tools/— action-tool wiring over the canonical registry (action_tools.py,tool_context.py,tool_provider.pyfor :class:~core.agent_harness.ports.ToolProvider).accounting/— session-scoped token accounting, LLM run metadata, and :class:~core.agent_harness.ports.TurnAccounting/ :class:~core.agent_harness.ports.RunRecordFactorydefaults.prompts/— action-agent and conversational-assistant prompt builders (pure string assembly; grounding text is supplied viaPromptContextProvider).prompt_context.pyimplements the default :class:~core.agent_harness.ports.PromptContextProvider.conversation_memory.py(recent-conversation rendering shared by prompts) lives here.error_reporting.py— default :class:~core.agent_harness.ports.ErrorReporter.llm_resolution.py— shared LLM provider/model resolution for prompts and action turns (default_llm_factory,resolve_provider_models).grounding/— reusable grounding cache and rendering contracts; surfaces inject surface-owned command registries instead of being imported here.session/— reusable agent session state (SessionCore), JSONL storage, prompt history, task registry, session-scoped background records, integration resolution (:mod:session.integration_resolution), andSessionManager(the lifecycle owner). See "Session lifecycle" below.
Session lifecycle (owned by SessionManager)
core.agent_harness.session.SessionManager is the single owner of session
create / resolve / rotate / restore / flush. Every surface delegates lifecycle
to it instead of re-implementing bootstrap + persistence:
- shell —
SessionBootstrapSpeccallsSessionManager().bootstrap(...)for the core startup mutations (persistent task registry + integration hydration), then layers shell-only UI concerns (theme, grounding providers, prompt history) on top. Interactive REPL entry calls :meth:SessionManager.open_storageonce the run is confirmed interactive;/newcalls :meth:SessionManager.rotate_in_place;/resumecalls :meth:SessionManager.rebind_for_resumethen :meth:SessionManager.restore_context. REPL exit calls :meth:SessionManager.closevia :meth:SessionManager.for_session. - gateway —
gateway/manager.pybootstraps the process via :meth:SessionManager.create(open_storage=False).gateway/storage/session/resolver.py::SessionResolverowns per-chat chat-id ↔ session-id binding + metadata; it delegatescreate/resolve/rotatetoSessionManager. Turn dispatch usesHeadlessAgentviagateway/turn_handler.py'sGatewayTurnHandlerwith :class:~core.agent_harness.tools.tool_provider.DefaultToolProviderbuilt from the live per-chat session each turn (same tool resolution as shell). There is no separate gateway-ownedAgentinstance. - headless — ephemeral in-memory sessions (
headless_dispatch.InMemorySessionStore) bypassSessionManagerby design: they never persist to JSONL and do not need create/resolve/rotate/close. Tool-calling turns still run through the shared harness; only session lifecycle is skipped.
Session (formerly ReplSession) is the in-memory session object used by every
surface, including headless gateway — it is not REPL-specific. Do not re-add
per-surface session bootstrap logic; extend SessionManager instead.
Agent construction pattern (Pattern A — canonical)
Every surface builds its runtime Agent the same way:
- Assemble surface-specific values (LLM, system prompt, tools, resolved integrations, iteration cap, observer).
- Pack them into an
AgentConfigdataclass. - Hand it to
build_agent(config).
from core.agent_harness.agent_builder import AgentConfig, build_agent
config = AgentConfig(
llm=llm_client, # or None to fall back to get_llm(LLMRole.AGENT)
system=system_prompt,
tools=tuple(agent_tools),
resolved_integrations=resolved,
max_iterations=6,
tool_resources={}, # optional
tool_hooks=None, # optional
on_runtime_event=observer_callback, # optional
)
agent = build_agent(config)
Action (turns/action_driver.py::_build_action_agent) and evidence
(turns/evidence_driver.py::_build_evidence_agent) assemble an
AgentConfig and call build_agent. The gateway turn path does not
construct a persistent Agent — it builds a fresh HeadlessAgent per turn with
:class:~core.agent_harness.tools.tool_provider.DefaultToolProvider
from the live chat session. When Agent.__init__'s signature changes,
agent_builder.py is the single edit site for harness surfaces that call
build_agent.
Agent context and data stores
Turn assembly starts in turns/orchestrator.py with
TurnSnapshot.from_session.
Do NOT reintroduce per-surface Agent subclasses that override
build_llm / build_system_prompt / build_tools / resolved_integrations
hooks. Those hooks were removed because they let each surface hide per-turn
configuration on self, which diverged routing across surfaces.
Two agent shapes (not one pattern with an exception)
The harness has two intentional agent shapes. This is a design, not a 4/4 uniformity claim with an exception bolted on:
- Tool-calling agent —
core.agent.Agent, the ReAct loop (think → call tools → observe) driven byllm.invoke. Built viaAgentConfig+build_agent(the construction pattern above). Used by the action, evidence/gather, and investigation agents. - Direct answer (no tools) —
orchestrator.stream_answer, one grounded text answer streamed viaclient.invoke_stream(theStreamAnswerFnseam inports.py). It does not useAgent: there is no tool loop and no observe step, and it streams on a different client method.
A new agent is one shape or the other: if it calls tools it is the tool-calling shape; if it answers directly without tools it is the direct-answer shape.
Contributor checklist (agent changes)
Before opening or merging an agent PR, confirm:
- Shape — State explicitly: tool-calling (
Agent/build_agent/ExecuteActions) or direct answer (StreamAnswerFn/invoke_stream, no tools). - Entrypoint docstring — The public function or class documents which shape it implements (three lines max; link here if helpful).
- Docs — Update this file when harness rules change (the assistant never
flows through
Agent.run(); keep any routing description consistent with that). - Seams — Inject through
ports.pycallables (StreamAnswerFn,ExecuteActions,EvidenceGatherer); do not import surface code intoagent_harness/. - Tests — Add or extend guards in
tests/core/agent_harness/test_agent_shapes.pywhen you introduce a new entrypoint or rename a shape seam.
Read order for new code: this file → turns/orchestrator.py (run_turn) →
core/agent/agent.py (facade + wiring) → core/agent/react_loop.py
(run_react_loop, the tool-calling algorithm).
Investigation agent — the tool-calling shape with a custom loop
tools/investigation/stages/gather_evidence/agent.py::ConnectedInvestigationAgent
composes the shared EventEmitterMixin and ToolFilterMixin mixins
(core.agent.mixins) instead of subclassing Agent, and owns a specialised
ReAct run() (seed calls, evidence collection, duplicate detection, stagnation
handling). It is still the tool-calling shape — a specialised loop that reuses
the two agent hooks by composition rather than delegating to the generic
Agent.run(). It assembles its config inline at the top of run().
Keep the loop primitive in core
The ReAct loop primitive is core.agent.Agent. agent_harness/ orchestrates it;
it does not re-implement it. Do not fork the loop here.
core/agent package (Agent is a facade, not the algorithm owner)
core/agent/ is a package with one file per responsibility (see
docs/NAMING.md for the naming convention). Agent
(in agent.py) is a thin facade: __init__ stores construction-time config and
run() resolves per-run context (from runtime_request= or initial_messages=)
and hands it to core.agent.react_loop.run_react_loop, which owns the actual
think → call-tools → observe algorithm.
core/agent/mixins.py—EventEmitterMixin(event dispatch),ToolFilterMixin(tool-narrowing hook),SteeringMixin(steer/follow_upto nudge a run in progress).Agentcomposes all three;ConnectedInvestigationAgent(tools/investigation/stages/gather_evidence/agent.py) composes the first two instead of subclassingAgent.core/agent/provider_hooks.py—ProviderHookDelegate, a fail-open wrapper aroundcore.provider.ProviderHooksapplied around each LLM call. A raised hook exception is logged and swallowed; it never breaks the loop.core/agent/loop_host.py—LoopHost, theProtocolrun_react_loopcalls back into.Agentimplements it via the mixins plus its own_transform_messages/_convert_to_llm/_before_request/_after_responseforwarders. The concreteProviderHookDelegatetype is anAgentimplementation detail, not part of the host contract, so any host can wire those four provider hooks however it likes.core/agent/run_io.py—AgentRunInput(resolved per-run inputs) andAgentRunResult(the loop's outcome).core.agentre-exportsAgentRunResultfor thefrom core.agent import AgentRunResultpath.core/agent/react_loop.py—ReactLoop(the loop as a method-object, phases_think/_handle_conclusion/_observe) andrun_react_loop(its thin functional entry).core/agent/agent.py— theAgentfacade:__init__(holds config),run()(builds the per-runAgentRunInputvia_build_run_inputand hands it torun_react_loop), and the_should_accept_conclusionoverride hook.
Do not reintroduce hook-method overrides on Agent itself (e.g. a subclass
overriding a private _before_provider_request-style method) — customize via
provider_hooks=ProviderHooks(...) at construction instead, which
ProviderHookDelegate applies. Subclassing remains the pattern for
_filter_tools and _should_accept_conclusion, which are genuine per-agent
overrides, not seams ProviderHooks covers.