Files
tracer-cloud--opensre/core/agent_harness/AGENTS.md
T
wehub-resource-sync 4b6817381b
CI (OpenClaw E2E) / openclaw test (push) Has been cancelled
CI / coverage-report (push) Has been cancelled
CI / test-kubernetes (push) Has been cancelled
CI / should-run-thorough (push) Has been cancelled
CI / test-thorough (cloudwatch-demo) (push) Has been cancelled
CI / test-thorough (flink-ecs) (push) Has been cancelled
CI / test-thorough (upstream-lambda) (push) Has been cancelled
CI / test-thorough (prefect-ecs-fargate) (push) Has been cancelled
Release / build-binaries (zip, opensre.exe, onefile, windows-latest, windows-x64) (push) Has been cancelled
Benchmark image — build + push to ECR (any adapter) / build + push (push) Has been cancelled
CI / quality (ubuntu-latest) (push) Has been cancelled
CI / test (tools-runtime) (push) Has been cancelled
CI / test (e2e-general) (push) Has been cancelled
CI / test (cli-runtime) (push) Has been cancelled
CI / test (e2e-provider-and-openclaw) (push) Has been cancelled
CI / test (integrations-and-misc) (push) Has been cancelled
Release / verify (push) Has been cancelled
Release / build-python-dist (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-15-intel, darwin-x64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-latest, darwin-arm64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04, linux-x64) (push) Has been cancelled
Release / publish-release (push) Has been cancelled
Release / publish-main-release (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-checks (no-LLM) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-live shard ${{ matrix.shard_index }} (push) Has been cancelled
Release / prepare (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04-arm, linux-arm64) (push) Has been cancelled
Synthetic Deterministic Tests / Synthetic offline (deterministic) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:10:45 +08:00

254 lines
14 KiB
Markdown

# 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_shell` anywhere under `agent_harness/`.** This is the whole
point of the package and is checked by
`tests/core/agent/test_import_boundaries.py`. The dependency direction is strictly
one-way: `interactive_shell -> agent_harness -> core`.
- `agent_harness/` may depend on `core/`, `config/`, and `platform/`. It must not
import `integrations/`, `tools/`, `surfaces/`, or `gateway/`. Integration and tool
behavior reaches the harness through ports in `platform/harness_ports.py`, wired at
startup via `install_harness_ports()` in `surfaces/interactive_shell/ui/output/boundary.py`
(called from `install_product_adapters()`).
It must not depend on terminal UI concerns (Rich rendering,
prompt-toolkit mutable UI state, slash dispatch, the shell `REGISTRY`). The
reusable session model, prompt history, grounding cache contracts, and task
records live here; `interactive_shell` supplies 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``AgentConfig` dataclass + `build_agent(config)`. The
single instantiation site for `core.agent.Agent` across all surfaces
(action, evidence, gateway). See "Agent construction pattern" below.
- `turns/` — the turn drivers that orchestrate `core.agent.Agent`:
- `action_driver.py``run_action_agent_turn`: one action tool-calling turn
over the ports. Uses `_build_action_agent` factory that returns an
`ActionTurnPlan`.
- `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 frozen `turn_snapshot`, so `turn_snapshot.resolved_integrations` is the
single source of truth for what the turn knows. Downstream components read
`turn_snapshot.resolved_integrations` (e.g. `action_driver._resolved_integrations_for_turn`
prefers it) rather than re-resolving. Do NOT reintroduce a per-component
integration resolution when `turn_snapshot` already carries it.
- `evidence_driver.py` — bounded evidence-gather loop. Uses
`_build_evidence_agent` factory that returns an `AgentConfig` handed to
`build_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. `tools` is required — surfaces that want a text-only
turn pass `NullToolProvider()` explicitly.
- `default_reasoning_client.py` — production
:class:`~core.agent_harness.ports.ReasoningClientProvider` default
(lazy ``LLMRole.REASONING`` client).
- `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.py` for :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.RunRecordFactory` defaults.
- `prompts/` — action-agent and conversational-assistant prompt builders (pure
string assembly; grounding text is supplied via `PromptContextProvider`).
`prompt_context.py` implements 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`), and `SessionManager` (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** — `SessionBootstrapSpec` calls `SessionManager().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_storage` once the run is confirmed interactive;
``/new`` calls :meth:`SessionManager.rotate_in_place`; ``/resume`` calls
:meth:`SessionManager.rebind_for_resume` then :meth:`SessionManager.restore_context`.
REPL exit calls :meth:`SessionManager.close` via
:meth:`SessionManager.for_session`.
- **gateway** — `gateway/manager.py` bootstraps the process via
:meth:`SessionManager.create` (``open_storage=False``).
`gateway/storage/session/resolver.py::SessionResolver` owns per-chat
chat-id ↔ session-id binding + metadata; it delegates `create` / `resolve` /
`rotate` to `SessionManager`. Turn dispatch uses `HeadlessAgent` via
`gateway/turn_handler.py`'s `GatewayTurnHandler` with
:class:`~core.agent_harness.tools.tool_provider.DefaultToolProvider`
built from the **live per-chat session** each turn (same tool resolution as
shell). There is no separate gateway-owned ``Agent`` instance.
- **headless** — ephemeral in-memory sessions (``headless_dispatch.InMemorySessionStore``)
bypass ``SessionManager`` by 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:
1. Assemble surface-specific values (LLM, system prompt, tools, resolved
integrations, iteration cap, observer).
2. Pack them into an `AgentConfig` dataclass.
3. Hand it to `build_agent(config)`.
```python
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 by `llm.invoke`. Built via `AgentConfig` +
`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 via `client.invoke_stream` (the `StreamAnswerFn` seam in
`ports.py`). It does **not** use `Agent`: 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:
1. **Shape** — State explicitly: tool-calling (`Agent` / `build_agent` /
`ExecuteActions`) or direct answer (`StreamAnswerFn` / `invoke_stream`, no tools).
2. **Entrypoint docstring** — The public function or class documents which shape
it implements (three lines max; link here if helpful).
3. **Docs** — Update this file when harness rules change (the assistant never
flows through `Agent.run()`; keep any routing description consistent with that).
4. **Seams** — Inject through `ports.py` callables (`StreamAnswerFn`,
`ExecuteActions`, `EvidenceGatherer`); do not import surface code into
`agent_harness/`.
5. **Tests** — Add or extend guards in
`tests/core/agent_harness/test_agent_shapes.py` when 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](../../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_up`
to nudge a run in progress). `Agent` composes all three;
`ConnectedInvestigationAgent`
(`tools/investigation/stages/gather_evidence/agent.py`) composes the first
two instead of subclassing `Agent`.
- `core/agent/provider_hooks.py` — `ProviderHookDelegate`, a fail-open wrapper
around `core.provider.ProviderHooks` applied around each LLM call. A raised
hook exception is logged and swallowed; it never breaks the loop.
- `core/agent/loop_host.py` — `LoopHost`, the `Protocol` `run_react_loop` calls
back into. `Agent` implements it via the mixins plus its own
`_transform_messages` / `_convert_to_llm` / `_before_request` /
`_after_response` forwarders. The concrete `ProviderHookDelegate` type is an
`Agent` implementation 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) and
`AgentRunResult` (the loop's outcome). `core.agent` re-exports `AgentRunResult`
for the `from core.agent import AgentRunResult` path.
- `core/agent/react_loop.py` — `ReactLoop` (the loop as a method-object, phases
`_think` / `_handle_conclusion` / `_observe`) and `run_react_loop` (its thin
functional entry).
- `core/agent/agent.py` — the `Agent` facade: `__init__` (holds config), `run()`
(builds the per-run `AgentRunInput` via `_build_run_input` and hands it to
`run_react_loop`), and the `_should_accept_conclusion` override 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.