diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml deleted file mode 100644 index c25900e..0000000 --- a/.github/workflows/pages.yml +++ /dev/null @@ -1,56 +0,0 @@ -name: Pages - -on: - push: - branches: [main] - workflow_dispatch: - -permissions: - contents: read - pages: write - id-token: write - -concurrency: - group: pages - cancel-in-progress: false - -jobs: - build: - name: Build site - runs-on: ubuntu-latest - - steps: - - name: Check out repository - uses: actions/checkout@v6 - - - name: Set up Python - uses: actions/setup-python@v6 - with: - python-version: "3.12" - - - name: Set up uv - uses: astral-sh/setup-uv@v8.1.0 - with: - enable-cache: true - cache-dependency-glob: uv.lock - - - name: Check generated site - run: uv run scripts/build_site.py --check - - - name: Upload Pages artifact - uses: actions/upload-pages-artifact@v4 - with: - path: docs/site - - deploy: - name: Deploy site - needs: build - runs-on: ubuntu-latest - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} - - steps: - - name: Deploy to GitHub Pages - id: deployment - uses: actions/deploy-pages@v4 diff --git a/.plans/37-plugin-system-and-filesystem.md b/.plans/37-plugin-system-and-filesystem.md new file mode 100644 index 0000000..e4a5a3a --- /dev/null +++ b/.plans/37-plugin-system-and-filesystem.md @@ -0,0 +1,215 @@ +# Plugin system and filesystem plugin — plan v2 + +Build the plugin seam and migrate the filesystem tools through it. This is the first implementation slice. It must land and receive implementation review before the MCP plugin starts. No release is cut between this plan and the MCP plugin plan. + +## Approved decisions + +1. **No implicit filesystem tools.** `Harness(...)` has no tools unless the caller passes `tools=` or `plugins=`. The replacement is `plugins=[FilesystemPlugin()]`. +2. **One canonical root.** `HarnessConfig.root` stays in core as run context. `FilesystemPlugin` uses `PluginContext.root`; it does not accept a second root. +3. **Static contributions are visible before connection.** `FilesystemPlugin` tools appear in `harness.tools` and `tool_schemas()` immediately after construction. Connected plugins can add a second, dynamic contribution during `connect()`. +4. **Direct extension stays simple.** Independent custom tools still use `tools=[ToolSpec(...)]`, and independent hooks still use `hooks=[Hook(...)]`. +5. **Plugin names are unique within one harness.** Names identify contribution origin. `MCPPlugin` therefore groups all servers for a harness. +6. **Plugins connect before `run_start`.** A connected hook applies to the first lazy run. A connection failure happens before a run starts, so `run_start` and `run_end` do not fire for that attempt. +7. **Parallel-LLM policy remains in core temporarily.** Keep `HarnessConfig.read_paths` and `write_paths` until `ParallelLlmPlugin` migrates. + +These are breaking pre-1.0 changes. Do not add compatibility aliases or an implicit default plugin. + +## Goal + +After this plan: + +```python +harness = Harness( + HarnessConfig(root="."), + model=model, + plugins=[ + FilesystemPlugin(tools=["read", "write", "edit", "search", "list", "glob", "jsonl_search"]), + ], + tools=[custom_tool], + hooks=[custom_hook], +) +``` + +The core run loop knows how to compose plugins, but it does not import or construct filesystem tools. + +## Public interface + +Add `thinharness/plugins/base.py` with these concepts. Exact private helper names can change, but the public shape and lifecycle must stay small. + +```python +@dataclass(frozen=True) +class PluginContext: + root: Path + +@dataclass(frozen=True) +class ToolOrigin: + plugin: str + source: str | None = None + attributes: Json = field(default_factory=dict) + +@dataclass(frozen=True) +class PluginContribution: + tools: tuple[ToolSpec, ...] = () + instructions: tuple[str, ...] = () + hooks: tuple[Hook, ...] = () + +@dataclass(frozen=True) +class PluginBinding: + static: PluginContribution = PluginContribution() + connect: Callable[[], AsyncContextManager[PluginContribution]] | None = None + +class Plugin(Protocol): + name: str + def bind(self, context: PluginContext) -> PluginBinding: ... +``` + +Rules: + +- `bind()` is synchronous and does no file or network I/O. +- One plugin object can bind to more than one harness. Each call returns independent binding state. +- Plugin names are non-empty and unique within one harness. Reject duplicates before binding. +- `PluginBinding.static` is validated and installed during harness construction. +- `connect` is optional. ThinHarness enters it lazily during `Harness.connect()` or before `run_start` on the first run. It can return dynamic tools, instructions, and hooks. +- Concurrent `connect()` calls share one connection attempt and cannot enter a binding twice. +- Dynamic contributions go through the full tool, hook-filter, approval, reserved-name, structured-output, callable-handler, and collision validation path. Commit the complete staged set only after every plugin connects successfully. +- A connection failure catches `BaseException`, closes entered bindings in reverse order, leaves no dynamic contribution installed, and allows a later retry. Cancellation propagates after cleanup. +- `Harness.aclose()` closes plugin bindings in reverse order, then closes an owned model. +- `Harness.plugins` is a read-only tuple of configured plugin objects for temporary feature bridges such as subagents. Core composition uses bindings, not plugin-specific inspection. +- Plugins run in-process and are trusted. There is no isolation, entry-point discovery, hot reload, dependency ordering, or plugin-to-plugin lookup. + +Observers are not part of this first interface. Current tracing does not implement a neutral read-only observer seam, and adding an unused interface now would be speculative. A later observability plan can add it with a real adapter. + +## Composition order + +Use one deterministic order: + +1. plugin static contributions in caller plugin order; +2. direct `tools=` and `hooks=` contributions; +3. plugin dynamic contributions in caller plugin order. + +System instructions are assembled as: + +1. `HarnessConfig.system_prompt`; +2. plugin-level instruction strings in contribution order; +3. the current skill summary while skills remain on the transitional built-in path; +4. `ToolSpec.instructions` in final tool order; +5. structured-output instructions through the existing output path. + +Plugin hooks use the existing strict/non-strict behavior and execute in contribution order. Always build a fresh `HookRegistry` from copied caller hooks plus plugin hooks; never mutate a caller-supplied registry. Validate dynamic hook filters before commit. Do not add topological ordering or hook priority. + +## Filesystem plugin + +Add `thinharness/plugins/filesystem.py` with `FilesystemPlugin`. + +- The plugin wraps the existing `FileTools` implementation instead of copying tool logic. +- Its default tool list is `read`, `write`, `edit`, `search`, `list`, and `glob` in that order. +- `jsonl_search` remains opt-in and stays behind this plugin because it shares the root, read policy, ripgrep process, truncation, and spill-output handling. +- `tools=` accepts an ordered sequence, rejects duplicates and unknown names, and preserves caller order. Do not accept a set because set order is not part of the interface. +- The plugin contributes `Workspace root: ` as plugin-level instructions. A harness without this plugin does not claim that it has a model-visible workspace. +- The plugin owns the current filesystem settings: `output_dir`, read and write path policies, read and output limits, search line limit, ripgrep timeout, and search exclusion globs. +- Keep `FileTools` as a public low-level deep module for callers that want direct `ToolSpec` values. Remove only the public `builtin_tools()` helper after all in-repo callers move to `FilesystemPlugin`. +- `bind()` must not create the root. Remove eager root creation from plugin composition. Missing-root read, list, glob, search, and JSONL calls return their normal empty or not-found result; write and spill paths create required parents when used. + +Keep `HarnessConfig.root`. Remove these filesystem-only fields from `HarnessConfig`: + +- `output_dir` +- `max_read_chars` +- `max_read_bytes` +- `max_tool_chars` +- `max_search_line_chars` +- `rg_timeout` +- `search_exclude_globs` + +Keep `read_paths` and `write_paths` temporarily as parallel-LLM policy. `FilesystemPlugin` receives its own path-policy settings explicitly. Document this temporary duplication and remove the core fields when `ParallelLlmPlugin` migrates. + +`Harness` and `FilesystemPlugin` must not create the root during construction or binding. A harness without the plugin has no filesystem side effect and no workspace instruction. + +## Transitional built-ins + +`HarnessConfig.builtin_tools` stays temporarily for skills, subagents, and parallel LLM until their own plugin plans land. It no longer accepts filesystem tool names. `builtin_tools=None` selects no transitional built-ins; every remaining built-in is explicit. An old filesystem name fails with an error that points to `FilesystemPlugin`. + +Update `SubAgentConfig` and `build_child_harness` only as much as needed to preserve current child filesystem choices: + +- add an explicit `plugins` field and count it as a valid tool source in `SubAgentConfig.validate_subagent`; +- pass child plugins through `build_child_harness` into `Harness`; +- convert in-repo child filesystem selections to `FilesystemPlugin`; +- default child inheritance can continue to pass already-resolved non-MCP `ToolSpec` values; +- do not redesign subagent factories or plugin inheritance in this slice. + +Mark the temporary `builtin_tools` path for removal in the later skills, parallel-LLM, and subagent plugin plans. Do not create a second hidden filesystem construction path for children. + +## Implementation steps + +1. Add plugin contracts, exports, and focused contract tests. +2. Refactor harness construction into collection, validation, and final assignment so static plugin contributions are atomic. +3. Add generic plugin connection management. During this unreleased intermediate slice, connect generic plugins first and the existing MCP stack second, both before `run_start`. If either path fails, close and reset both paths and remove all staged dynamic contributions so retry starts clean. Close MCP first, then generic plugins, then an owned model. +4. Add full dynamic contribution staging, validation, rollback, retry, concurrent-connect serialization, and reverse-order close. +5. Add `ToolOrigin` to `ToolSpec`. Stamp plugin tools with their unique plugin name while direct tools can keep `origin=None`. +6. Implement `FilesystemPlugin` on top of `FileTools` and move filesystem configuration except temporary parallel-LLM path policy out of `HarnessConfig`. +7. Migrate unit tests, end-to-end journeys, examples, README, `docs/docs.md`, and the hand-maintained `docs/site/explainer/index.html` to explicit filesystem plugins. +8. Remove core imports of `tools.filesystem`, `DEFAULT_BUILTIN_TOOLS`, and the exported `builtin_tools()` helper. Migrate its direct caller in `tests/unit/test_tool_retry.py`. +9. Add an architecture test that fails if `thinharness/core.py` imports `thinharness.plugins.filesystem` or `thinharness.tools.filesystem`. + +## Behavior contract changes before implementation + +After this plan review and before code changes, update `docs/behavior.md` with: + +- explicit plugin composition and no implicit filesystem tools; +- bind, static contribution, lazy connect, atomic commit, retry, and reverse close rules; +- deterministic tool, instruction, and hook ordering; +- duplicate-name errors and plugin origin; +- one canonical root and the absence of filesystem side effects without `FilesystemPlugin`; +- plugin connection occurring before `run_start`, and the run toolset freeze occurring after both; + +Edit only affected sections. + +## Tests + +Add focused tests for: + +- static plugin tools and instructions visible before `connect()`; +- direct custom tools combined with plugin tools; +- duplicate plugin names, duplicate tools across plugins, and collisions between a plugin and `tools=`; +- copied hook registries, plugin hook ordering, dynamic hook-filter validation, and existing strict-hook behavior; +- lazy connection before `run_start`, one connection across several runs, concurrent connection, reverse close, failed-open rollback, cancellation, and successful retry; +- full dynamic tool validation, including reserved names, structured output, approval policy, and callable handlers; +- no partial dynamic tools, instructions, or hooks after failure; +- toolset freeze after plugin connection and run-start hooks; +- reuse of one plugin object across two harnesses with independent binding state; +- no root creation and no workspace instruction without `FilesystemPlugin`, plus missing-root tool behavior with the plugin; +- filesystem default selection, explicit ordering, empty selection, duplicates, unknown names, path rules, spill files, and opt-in JSONL search; +- approval, resume, streaming, tracing, and subagent behavior with plugin-provided tools. + +Run: + +```bash +uv run pytest tests/unit/test_harness.py tests/unit/test_hooks.py tests/unit/test_streaming.py tests/unit/test_resume.py tests/unit/test_approvals.py +uv run pytest tests/unit/test_file_tools.py tests/unit/test_subagents.py tests/unit/test_tool_retry.py tests/unit/test_parallel_llm.py tests/unit/test_parallel_tools.py tests/unit/test_skills.py +uv run pytest +uv run ruff check . +uv run pyright +``` + +Use the actual filesystem test file names present at implementation time if they differ. + +## Success criteria + +- The plugin seam supports static and connected adapters without exposing resources to callers. +- Filesystem tools are available only through explicit `FilesystemPlugin` use or direct `ToolSpec` registration. +- `jsonl_search` is an option on `FilesystemPlugin`, not a separate plugin. +- Independent custom tools and hooks remain direct constructor inputs. +- Core does not import or construct filesystem implementations. +- Failed plugin connection leaves the harness clean and retryable. +- The full test suite, Ruff, and Pyright pass. + +## Out of scope + +- MCP migration, except for making generic connection management possible. +- Skills, subagents, parallel LLM, providers, or tracing as plugins. +- Package distribution splitting. +- Plugin discovery, package manifests, hot reload, UI contributions, sandboxing, or dependency graphs. +- Dynamic tool-list change notifications after connection. + +## Review record + +One Codex, Claude, and GLM panel round reviewed plan v1 together with the MCP plan. Plan v2 applies the verified findings on parallel-LLM path policy, root side effects, hook registry copying, instruction order, dynamic validation, concurrent connection, temporary MCP coexistence, subagent validation, and missing tests. The approved product decisions are recorded above. No second plan-review round will run. diff --git a/.plans/38-mcp-plugin.md b/.plans/38-mcp-plugin.md new file mode 100644 index 0000000..ffc489c --- /dev/null +++ b/.plans/38-mcp-plugin.md @@ -0,0 +1,173 @@ +# MCP plugin — plan v2 + +Migrate MCP after `.plans/37-plugin-system-and-filesystem.md` lands and its implementation review is complete. This is the second implementation slice and the first connected production plugin. No release is cut between the two slices. + +## Approved decisions + +1. **One MCP plugin per harness.** Plugin names are unique, `MCPPlugin.name` is fixed to `"mcp"`, and one plugin groups every server for that harness. +2. **Keep server adapters.** `MCPServer`, `MCPServerStdio`, `MCPServerSSE`, and `MCPServerStreamableHTTP` remain transport and tool-conversion adapters. `MCPPlugin` owns harness composition and lifecycle. +3. **One discovered snapshot per binding.** MCP tool changes after connection are ignored until a new harness binding is created. Do not implement `notifications/tools/list_changed`. +4. **Subagent MCP settings stay temporarily.** Existing `SubAgentConfig.inherit_mcp_servers` and `mcp_servers` remain until the subagent plugin plan. The bridge moves out of core and builds child `MCPPlugin` values explicitly. +5. **Connection precedes `run_start`.** MCP setup failure occurs before a run starts and does not fire run lifecycle hooks. + +This plan removes `HarnessConfig.mcp_servers`. Do not support both configuration paths. + +## Goal + +After this plan: + +```python +harness = Harness( + HarnessConfig(root="."), + model=model, + plugins=[ + MCPPlugin(servers=[ + MCPServerStdio("python", ["server.py"], tool_prefix="docs"), + ]), + ], +) +``` + +`Harness.connect()` is fully generic. Core does not import MCP classes, resolve MCP server ids, discover MCP tools, or own an MCP-specific exit stack. + +## Plugin behavior + +Add `thinharness/plugins/mcp.py` with `MCPPlugin`. + +- `bind()` resolves and validates configuration without I/O. +- The binding has no static tools. +- Its connector owns a private `AsyncExitStack`, enters servers in caller order, discovers one tool snapshot from each server, and returns one dynamic `PluginContribution`. +- The connector keeps entered servers open until the harness closes. +- Generic harness composition validates the complete discovered set before committing any tool. +- Because an async context manager whose `__aenter__` fails is not owned by the harness stack, the MCP connector itself catches `BaseException`, closes its private stack in reverse order, and re-raises. This covers connection, discovery, schema, collision, and cancellation failures. +- Repeated `connect()` and repeated runs reuse the same binding and discovered tools. +- `Harness.aclose()` closes the plugin once. Reusing the same `MCPServer` wrapper across parent and child bindings keeps the current FastMCP reference-counted session sharing. + +Keep server filtering, prefixing, schema cleanup, error conversion, timeout behavior, optional dependency behavior, and transport ownership inside the existing MCP implementation module. + +## Generic tool origin + +Plan 37 adds `ToolOrigin`. Use it for MCP tools: + +```python +ToolOrigin( + plugin="mcp", + source=resolved_server_id, + attributes={"tool_name": original_tool_name}, +) +``` + +Remove the MCP-specific fields from the core tool contract: + +- remove `McpToolInfo`; +- remove `ToolSpec.mcp`; +- remove `"mcp"` from `ToolKind` after all checks use `ToolOrigin`. + +Keep `ToolKind` only for remaining framework control behavior such as the reserved subagent tool. Do not turn every origin into a new `ToolKind` value. + +`MCPPlugin.name` is fixed to `"mcp"`, so tracing derives `mcp.server.id` and `mcp.tool.name` from `ToolOrigin`, and subagent parent-tool inheritance excludes `origin.plugin == "mcp"` unless MCP inheritance is explicit. Tool result metadata remains unchanged because it is model-visible behavior. + +## Server identity + +Move duplicate server-id resolution from `Harness` into the MCP plugin binding. Preserve the current deterministic base ids and `-2`, `-3` suffix behavior. + +Server identity must be binding-local. Remove `MCPServer._resolved_id` and `resolve_id()` rather than leaving mutable identity on a shared wrapper. A private bound-server adapter carries `(server, resolved_id)` and builds handlers that pass the resolved id into result normalization. Both `ToolOrigin.source` and model-visible `ToolResult.metadata["mcp_server_id"]` use that bound id. Keep the public server wrapper responsible for connection and raw calls. + +Add tests for: + +- duplicate ids inside the one plugin; +- one server object reused by two harnesses with different collision neighbors; +- stable trace and result metadata after both harnesses connect; +- the public base id before binding. + +## Subagent bridge + +The subagent module is not core, so it can keep a temporary MCP-specific bridge until `SubagentsPlugin` exists. + +- Explicit `SubAgentConfig.mcp_servers` values become a child `MCPPlugin`. +- `inherit_mcp_servers=True` finds the fixed-name MCP plugin in the read-only `parent.plugins` tuple, copies its server adapters by identity, then adds explicit child servers without duplicate objects. +- Default parent-tool inheritance still excludes resolved MCP `ToolSpec` values. The child must connect its own MCP plugin so handlers and lifecycle are valid. +- Remove reads of `parent._mcp_servers` and `child.config.mcp_servers`; do not add a replacement MCP field to core. +- Keep current sharing, override, union, failure rollback, tracing, and cleanup behavior. +- Record the bridge for deletion in the later subagent plugin plan; do not add a generic plugin inheritance flag now. + +## Implementation steps + +1. Add `MCPPlugin` and public exports. +2. Remove mutable resolved ids from server wrappers and move resolution plus result attribution into binding-local MCP state. +3. Move server entry and discovery from `Harness._ensure_mcp_connected()` into the plugin connector, with its own failed-entry cleanup. +4. Delete `_mcp_servers`, `_mcp_stack`, `_mcp_connected`, `_resolve_mcp_server_ids()`, and `_ensure_mcp_connected()` from core. +5. Make `Harness.connect()` delegate only to generic plugin connection management from plan 37. +6. Remove `HarnessConfig.mcp_servers` and migrate every caller to `plugins=[MCPPlugin(...)]`. +7. Replace `McpToolInfo` with `ToolOrigin` in MCP conversion, tracing, tests, and subagent inheritance. +8. Implement the temporary subagent bridge without adding MCP knowledge back to core. +9. Add an architecture test that fails if `thinharness/core.py` directly imports MCP modules or contains MCP lifecycle logic. Transitive MCP reachability through the temporary subagent bridge remains until `SubagentsPlugin` migrates. +10. Update README, `docs/docs.md`, end-to-end journeys, examples, exports, changelog, and MCP-1/MCP-5 in `docs/behavior.md`. + +## Behavior contract changes before implementation + +After this plan review and before code changes, update the MCP section in `docs/behavior.md`: + +- replace `HarnessConfig.mcp_servers` with explicit `MCPPlugin` composition; +- state lazy generic plugin connection and one discovered snapshot per binding; +- state binding-local server ids and origin metadata; +- preserve filter, prefix, schema, result, error, timeout, optional dependency, session sharing, cancellation, and bounded cleanup rules; +- preserve explicit subagent inheritance while naming the temporary bridge; +- update MCP-1 server-id ownership and MCP-5 from `kind="mcp"`/`McpToolInfo` to `ToolOrigin`; +- leave the run toolset freeze wording owned by plan 37. + +Edit only affected sections. + +## Tests + +Retain or migrate all existing MCP behavior tests. Add focused coverage for: + +- MCP tools absent before connect and installed atomically after connect; +- explicit `connect()` and first-run lazy connection; +- repeated runs reuse one discovered snapshot; +- a second fixed-name `MCPPlugin` is rejected during harness construction; +- collisions with static plugin tools, direct tools, structured-output tools, and tools from another MCP server; +- failed second-server connection and failed discovery close the first server and allow retry; +- cancellation during connect and close; +- no dynamic contribution after failed connection; +- reverse close order across MCP and other connected plugins; +- binding-local server ids and one server wrapper shared across harnesses; +- tracing attribution after an after-tool hook rewrites output; +- resume and approval flows do not serialize MCP connection details; +- subagent no-inherit, explicit, inherit, union, shared-session, and failure cases, including a child whose second server fails while the parent keeps a shared server live; +- base import and wrapper construction without MCP dependencies. + +Run: + +```bash +uv run pytest tests/unit/test_mcp.py tests/unit/test_mcp_optional_dependency.py +uv run pytest tests/unit/test_subagents.py tests/unit/test_tracing.py tests/unit/test_resume.py tests/unit/test_approvals.py +uv run pytest +uv run ruff check . +uv run pyright +``` + +Run the deterministic MCP end-to-end journey. Report credential-based skips separately and do not count them as passes. + +## Success criteria + +- MCP is enabled only through `MCPPlugin`. +- Core contains no direct MCP imports, state, lifecycle, identity, or discovery logic; the temporary transitive dependency is isolated in the subagent module. +- Generic plugin rollback and cleanup preserve every current MCP lifecycle guarantee. +- `ToolSpec` contains generic origin data instead of MCP-specific fields. +- Parent and child harnesses retain explicit MCP inheritance and safe shared-session behavior. +- Existing MCP transport and result semantics do not change. +- The focused suite, full suite, Ruff, Pyright, and deterministic MCP journey pass. + +## Out of scope + +- Replacing FastMCP or changing its pinned version. +- MCP prompts, resources, sampling, elicitation, OAuth, tasks, server instructions, or provider-native MCP. +- Dynamic MCP tool-list updates. +- Generic plugin inheritance or child-harness factories. +- Subagents as a plugin. +- Package distribution splitting. + +## Review record + +One Codex, Claude, and GLM panel round reviewed plan v1 together with the plugin-system plan. Plan v2 applies the verified findings on unique plugin identity, failed connector entry, binding-local server ids, result metadata, subagent access, architecture-test scope, behavior-document ownership, and combined failure tests. The approved product decisions are recorded above. No second plan-review round will run. diff --git a/.plans/39-skills-and-parallel-llm-plugins.md b/.plans/39-skills-and-parallel-llm-plugins.md new file mode 100644 index 0000000..7fcd117 --- /dev/null +++ b/.plans/39-skills-and-parallel-llm-plugins.md @@ -0,0 +1,331 @@ +# Skills and parallel LLM plugins — plan v2 + +Migrate the remaining non-subagent built-in tools onto the plugin seam created by plans 37 and 38. This slice adds `SkillsPlugin` and `ParallelLlmPlugin`, removes their configuration and composition logic from core, and leaves subagent composition for a later plan. + +## Resolved decisions + +1. **Keep both low-level modules.** `SkillRegistry` remains the deep module for skill discovery and execution. `ParallelLlmTool` remains the deep, renameable module for one-shot batches. The plugins own harness composition, defaults, and origin attribution rather than copying either implementation. +2. **Skills stay static.** `SkillsPlugin` discovers one catalog when the plugin object is constructed. Its `bind()` method performs no I/O, and its selected tools and summary are visible immediately after harness construction. Reusing one plugin object across harnesses reuses that catalog; callers construct a new plugin to rediscover added or removed skills. +3. **The skill catalog is frozen, not skill contents.** Discovery metadata, selected names, and the summary are fixed at plugin construction. Existing `SKILL.md` content, trees, and scripts remain live and are read when `skill_read` or `skill_run` executes. +4. **Skill paths keep their current meaning.** Relative `skills_dir` values resolve from the process working directory through `SkillRegistry`, not from `HarnessConfig.root`. Documentation must state this difference from root-scoped filesystem and parallel tools. +5. **Skill tool selection stays explicit.** `SkillsPlugin` requires a non-empty ordered selection of `skill_read`, `skill_run`, or both. Enabling skill discovery does not silently enable script execution. +6. **The parallel plugin uses the harness model by default.** Add the configured harness `Model` to `PluginContext`. `ParallelLlmPlugin(model=None)` borrows that model; an explicit model object is caller-owned; an explicit model string uses plugin-owned provider settings and the existing per-call create/close behavior. +7. **Alternate parallel models do not inherit hidden parent settings.** Provider and request settings are valid only when `model` is a string. Reject them for `model=None` or a model object instead of storing ignored values. +8. **Plugin names are runtime-fixed.** `SkillsPlugin.name == "skills"` and `ParallelLlmPlugin.name == "parallel_llm"` cannot be changed on an instance or class, including a subclass. One plugin of each type is allowed per harness; one SkillsPlugin accepts several skill directories. +9. **Subagents are not redesigned here.** Named child agents use their existing `plugins` field for explicit skills and parallel LLM. Remove the now-empty child `builtin_tools` path. A small SkillsPlugin-specific inheritance bridge preserves default and `inherit_parent_tools=True` behavior until `SubagentsPlugin` replaces child composition. +10. **The main built-in selector stays temporarily.** `HarnessConfig.builtin_tools` remains only to select the deferred `subagent` tool. Skill and parallel names fail with errors that point to their plugins. + +These are breaking pre-1.0 changes. Do not add aliases, fallback configuration reads, or compatibility constructors. + +## Goal + +After this plan, callers compose both features explicitly: + +```python +from thinharness import Harness, HarnessConfig, ParallelLlmPlugin, SkillsPlugin + +harness = Harness( + HarnessConfig(root="."), + plugins=[ + # Relative skill paths use the process working directory, not root. + SkillsPlugin(".agents/skills", tools=["skill_read"]), + ParallelLlmPlugin(), + ], +) +``` + +`thinharness/core.py` does not import skill or parallel-LLM implementation modules, construct either tool family, discover skills, render skill summaries, own parallel path policy, or copy parallel provider settings. + +## Plugin context + +Extend the existing context by one stable core dependency: + +```python +@dataclass(frozen=True) +class PluginContext: + root: Path + model: Model +``` + +Core constructs the model before binding plugins, as it does now. Do not add `Harness`, `HarnessConfig`, credentials, provider settings, plugin lookup, child factories, tracing, or mutable services to the context. + +A plugin must not close `context.model`; model ownership remains with the harness or caller. Add contract coverage that one plugin object bound to two harnesses sees each harness's own root and model. + +## SkillsPlugin + +Add `thinharness/plugins/skills.py`: + +```python +SkillsPlugin( + skills_dir: str | Path | Sequence[str | Path], + *, + selected_skills: Sequence[str] | None = None, + tools: Sequence[Literal["skill_read", "skill_run"]], +) +``` + +Behavior: + +- The fixed name is `"skills"`. +- Reject a set for `skills_dir` or `tools`. Reject an empty skill-directory sequence, an empty tool selection, duplicate tools, and unknown tools when the plugin is constructed. +- Construct one `SkillRegistry` in the plugin constructor. Discovery and selected-skill validation therefore occur before harness binding. +- A valid tool selection with no discovered skills contributes no tools and no summary. `selected_skills` naming a missing skill still fails during construction. +- `bind()` selects the requested `ToolSpec` values in caller order and returns one static `PluginContribution` without calling filesystem metadata functions. +- When at least one selected tool exists, contribute the compact skill summary once as plugin instructions. +- Make summary wording conditional. When `skill_read` is absent, do not tell the model to call it. +- Generic normalization assigns `ToolOrigin(plugin="skills", source=)`. +- `skill_run` remains sequential. `skill_read` remains parallel-safe. +- Keep current frontmatter, containment, tree rendering, truncation, runner selection, working directory, merged output, timeout, and result metadata behavior inside `SkillRegistry`. +- Keep `Skill`, `SkillRegistry`, argument types, and parser helpers public at their current module level. + +The frozen catalog contains skill names, paths, metadata, selection, and summary text. `skill_read` still reads current file content and builds the current tree at invocation time. `skill_run` still executes the current script file. Adding or removing skill entries after plugin construction does not change the catalog; editing a discovered skill's files remains visible. + +The skill summary becomes a normal plugin instruction. Its position follows caller plugin order, before all per-tool instructions under PLUGIN-8. This intentionally replaces the old fixed transitional-summary position. + +## ParallelLlmPlugin + +Add `thinharness/plugins/parallel_llm.py` with fixed name `"parallel_llm"`. It contributes one static tool named `parallel_llm` by constructing the existing `ParallelLlmTool` at bind time with the canonical context root and resolved model. + +The plugin accepts: + +- `model: Model | str | None = None`; +- `description` and `instructions`, defaulting to the current parallel defaults; +- `read_paths`, `write_paths`, and `max_prompts`; +- for a string model only: `api_key`, `base_url`, request timeout, retry count, retry backoff, temperature, max tokens, effort, and extra body. + +Use `None` sentinels for optional provider/request arguments on the plugin. For a string model, map omitted values to the current `ParallelLlmTool` defaults. For `model=None` or a model object, reject any supplied provider or request argument because the borrowed model already owns those settings. + +Reject `max_prompts < 1` during plugin construction. Do not add `root`, output schema settings, or a caller-settable tool name to the plugin. Callers that need a renamed or structured-output batch tool continue to use `ParallelLlmTool(...).spec()` through direct `tools=` composition. + +### I/O-free binding + +`ParallelLlmTool.__init__` currently resolves its root. Add a private `_root_is_resolved: bool = False` argument, matching `FileTools`. The plugin passes the already-resolved `PluginContext.root` with `_root_is_resolved=True`. Do not call `Path.resolve()`, `Path.exists()`, `Path.stat()`, provider inference, or network code during `ParallelLlmPlugin.bind()`. + +### Model ownership + +- `model=None`: bind to `PluginContext.model`; the plugin does not close it. +- `model=`: bind to that object; the caller owns it and the plugin does not close it. +- `model="provider:model"`: preserve `ParallelLlmTool` behavior — infer a model for each batch invocation and close the created provider after the batch, including schema-resolution, request, and cancellation failures. + +Preserve all current parallel behavior: + +- independent stateless prompts and fresh sessions; +- ordered sparse results with bounded concurrency; +- no parent system prompt, tools, memory, or continuation; +- file input and output policies under `PluginContext.root`; +- prompt caps, atomic JSON files, cancellation, and batch-local request counts; +- provider transport retries inside one logical request; +- no model override in model-visible arguments; +- default description and per-tool instructions; +- text-only plugin batches. + +Batch model requests and tokens remain outside parent `RunUsage` and `max_model_requests`. The parent counts one `parallel_llm` tool call toward `max_tool_calls`; batch metadata reports its own `model_requests`, `total`, `succeeded`, and `failed` values. + +Generic normalization assigns `ToolOrigin(plugin="parallel_llm", source="parallel_llm")`. + +Remove `"parallel_llm"` from `ToolKind` because no core control flow uses it. Remove `kind="parallel_llm"` from `ParallelLlmTool.spec()`, so direct and plugin-created specs use the valid default kind `"user"`. Update the `ToolKind` literal and its runtime validation set, tests, and changelog together. The low-level tool's execution behavior does not change. + +## Core removals and fail-loud guards + +Remove from `HarnessConfig`: + +- `skills_dir`; +- `selected_skills`; +- `read_paths`; +- `write_paths`; +- `builtin_parallel_llm_model`; +- `builtin_parallel_llm_temperature`; +- `parallel_llm_max_prompts`. + +Add a `model_validator(mode="before")` that rejects each removed field by name with a plugin migration message. Do not rely on Pydantic's default extra-field behavior. Replace the old `selected_skills requires skills_dir` test with explicit removed-field tests. Do not change all unknown extras to `extra="forbid"` in this slice. + +Remove from `Harness`: + +- the `skills=` constructor argument; +- `self.skills` and `_skills_enabled`; +- skill discovery, selected-skill validation, summary rendering, and skill tool validation; +- `create_parallel_llm_tool(self)` and parallel provider-setting projection; +- direct imports of `SkillRegistry` and the parallel-LLM module. + +Delete `create_parallel_llm_tool(parent)` and its exports. Keep `ParallelLlmTool` as the direct low-level interface. + +Reduce the main built-in candidate list to `subagent`. Keep `HarnessConfig.builtin_tools` until the subagent plugin plan. Requests for `skill_read` or `skill_run` must point to `SkillsPlugin`; requests for `parallel_llm` must point to `ParallelLlmPlugin`. Other unknown values keep the normal unknown-built-in error. + +Remove `SubAgentConfig.builtin_tools`. Extend its existing before-validator to reject the removed field by name, as it already does for `background`, so a second valid tool source cannot hide the mistake. Update validation to: + +- detect recursive `subagent` exposure only through direct `tools`; +- reject `inherit_parent_tools` combined with `plugins` or direct `tools`; +- require named subagents to define `plugins`, `tools`, `inherit_parent_tools=True`, `inherit_mcp_servers=True`, or `mcp_servers`; +- use that exact source list in the validation error. + +Migrate every caller, including the rejected Bash built-in test, instead of leaving obsolete child configuration examples. + +## Temporary subagent behavior + +Preserve these behaviors while deferring `SubagentsPlugin`: + +- `SubAgentConfig.plugins` accepts `SkillsPlugin` and `ParallelLlmPlugin` for explicit child composition. +- A non-inheriting named child that wants skills must configure its own `SkillsPlugin`; it does not implicitly reuse the parent's catalog. +- A default child and a child with `inherit_parent_tools=True` continue to receive the parent's resolved, non-approval tools. +- Resolved MCP tools remain excluded unless MCP inheritance is explicit. +- Find the exact parent `SkillsPlugin` with an `isinstance` scan over the read-only `parent.plugins` tuple, matching the MCP bridge pattern. +- Only when that plugin exists, exclude inherited resolved tools whose origin plugin is `"skills"` **and** whose names are in that exact plugin's configured tool selection. Generic direct tools or another custom plugin with a caller-supplied `ToolOrigin(plugin="skills")` remain inherited. +- Rebind the same parent SkillsPlugin object in the child instead of copying those selected specs. The child receives the same registry object, catalog, tool order, and one summary without a second discovery pass. +- Insert the rebound SkillsPlugin after the current filesystem instruction-only plugin in the temporary inherited-plugin list. This fixes child instruction order: filesystem root instruction, skill summary, then per-tool instructions. +- Other inherited static tools, including `parallel_llm`, keep the current resolved-handler inheritance behavior. A child with a model override therefore keeps the parent batch handler and parent batch model, matching current `inherit_parent_tools` behavior. +- Keep the existing filesystem instruction-only bridge and MCP server bridge. +- Child model, tracing, hooks, limits, output, lifecycle, and cleanup behavior do not change. + +Mark the SkillsPlugin-specific bridge, filesystem instruction bridge, and MCP bridge for deletion in the later subagent plugin plan. Do not add generic plugin inheritance, clone methods, or child factories now. + +## Behavior contract changes before implementation + +After plan review and before code changes, update only affected sections of `docs/behavior.md`: + +- extend PLUGIN-3 to state that `PluginContext` contains the canonical root and configured model while binding remains synchronous and I/O-free; +- update PLUGIN-8 so the skill summary is a plugin instruction ordered by plugin position; +- remove the temporary parallel-LLM path-policy wording from FILESYSTEM-PLUGIN-5; +- add a Skills Plugin section covering one fixed-name plugin per harness, explicit tool selection, constructor-time catalog discovery, static visibility, cwd-relative paths, frozen catalog/live content, summary conditions, tool behavior, and temporary child inheritance; +- add a Parallel LLM Plugin section covering one fixed-name plugin per harness, explicit composition, model ownership, provider-setting validation, root/path policy, text-only behavior, parent-run accounting exclusion, results, retries, cancellation, and the direct `ParallelLlmTool` escape hatch; +- update PROVIDER-RETRY-6 so plugin-owned string models use their own settings while a plugin borrowing the harness model uses that model's configured transport retries. + +Do not update unrelated behavior sections. + +## Architecture guard + +Extend `tests/unit/test_architecture.py` with a core-only check. Reject these tokens in `thinharness/core.py`: + +- `tools.skills`, `SkillRegistry`, `skills_dir`, `selected_skills`, `_skills_enabled`, and `prompt_summary`; +- `tools.parallel_llm`, `create_parallel_llm_tool`, `builtin_parallel_llm`, and `parallel_llm_max_prompts`. + +Scope the test to direct core source. Temporary imports and bridges in `thinharness/subagents.py` remain allowed until its plugin migration. + +Also test that `ParallelLlmPlugin.bind()` does not call root-resolution or filesystem metadata methods and does not infer or contact a provider. + +## Implementation steps + +1. Update the affected behavior contracts. +2. Add `model` to `PluginContext` and migrate every binding call and contract test. +3. Add `SkillsPlugin`, focused tests, and public exports. +4. Add the private resolved-root path to `ParallelLlmTool`; add `ParallelLlmPlugin`, focused tests, and public exports. +5. Migrate main harness and explicit child callers from skill and parallel built-ins to plugins. +6. Implement the exact temporary inherited-skills bridge. +7. Add fail-loud removed-field guards, then remove the listed core fields, constructor arguments, state, helpers, imports, `ToolKind` value, and child built-in path. +8. Add the concrete architecture and I/O-free binding tests. +9. Update README, `docs/docs.md`, all checked-in site pages, examples, exports, changelog, and end-to-end journeys. +10. Regenerate the README-derived site with `uv run scripts/build_site.py` and verify `uv run scripts/build_site.py --check`. + +## Tests + +Retain the existing `SkillRegistry` and `ParallelLlmTool` behavior suites. Add focused coverage for: + +### Plugin context + +- root and model identity passed to static and connected test plugins; +- one plugin object bound to two harnesses with independent context values; +- plugins do not close caller-owned models. + +### Skills + +- constructor rejects unordered or empty skill directories, and unordered, empty, duplicate, or unknown tool selections; +- constructor discovers recursively, filters selected skills, and rejects duplicate or missing selected names; +- relative skill paths preserve process-working-directory resolution across harnesses with different roots; +- selected tools and summary are static and visible immediately after harness construction; +- caller tool order and plugin-order-dependent summary order are preserved; +- no discovered skills contributes no tools or summary, while a missing selected skill fails; +- adding a skill after construction is ignored; editing a discovered `SKILL.md` or script remains visible to execution; a new plugin sees the added skill; +- plugin reuse across harnesses shares one catalog and registry object; +- runtime name mutation and subclass overrides are rejected; +- origin stamping and collisions with direct or other plugin tools; +- custom direct tools and custom plugins with `origin.plugin == "skills"` remain inherited unless they match the exact parent SkillsPlugin selection; +- summary appears once, and its wording does not mention unavailable `skill_read`; +- existing read, run, timeout, containment, truncation, runner, and sequential behavior; +- explicit child plugin composition and inherited parent skill tools plus exactly one summary. + +### Parallel LLM + +- static visibility, runtime-fixed plugin/tool name, origin, description, and instructions; +- I/O-free bind with the resolved context root; +- `model=None` binds each harness model independently when one plugin object is reused; +- explicit model objects are borrowed and never closed; +- provider/request options are rejected unless model is a string; +- explicit model strings use plugin request settings and close created providers on success, failure, schema failure, and cancellation; +- `max_prompts` fails during plugin construction; +- canonical root, read/write policies, prompt cap, concurrency, ordering, batch-local accounting, output files, retries, cancellation, and text-only output; +- one parent tool call is counted while nested batch requests and tokens stay outside parent run usage and limits; +- no parent system prompt or nested tool execution; +- direct `ParallelLlmTool` remains renameable, supports structured output, and now emits kind `"user"`; +- explicit child plugin composition and inherited resolved-tool behavior, including a child model override retaining the parent's batch model. + +### Removal and integration + +- each removed `HarnessConfig` field raises its own migration error; +- `Harness(skills=...)` raises rather than being ignored; +- `builtin_tools=["skill_read"]`, `builtin_tools=["skill_run"]`, and `builtin_tools=["parallel_llm"]` give plugin migration errors; +- `builtin_tools=["subagent"]` still works and other unknown names keep the normal error; +- `SubAgentConfig.builtin_tools` raises even when another valid source is present; +- updated named-subagent validation uses the exact remaining source list; +- plugin/direct/structured-output tool collisions remain atomic; +- resume and approval state do not serialize plugin configuration or `PluginContext.model`; +- system instructions preserve plugin order and place all per-tool instructions last. + +Run: + +```bash +uv run pytest tests/unit/test_skills.py tests/unit/test_parallel_llm.py tests/unit/test_plugins.py tests/unit/test_architecture.py tests/unit/test_file_tools.py +uv run pytest tests/unit/test_harness.py tests/unit/test_subagents.py tests/unit/test_tracing.py tests/unit/test_resume.py tests/unit/test_approvals.py +uv run pytest +uv run ruff check . +uv run pyright +uv run scripts/build_site.py --check +uv run --env-file .env python tests/e2e/skills_journey.py +uv run --env-file .env python tests/e2e/parallel_llm_agent_journey.py +uv run --env-file .env python tests/e2e/parallel_llm_tool_journey.py +``` + +Report credential-based skips separately and do not count them as passes. + +## Documentation and caller migration + +Update: + +- README feature text and examples, including the current `builtin_parallel_llm_model` text; +- `docs/docs.md` configuration, plugin, skills, parallel batch, and child-agent examples; +- `docs/site/explainer/index.html` architecture tree, composition text, and feature tables; +- hand-written `docs/site/index.html` parallel-LLM card; +- the README-derived `docs/site/about/index.html` through the site builder; +- `tests/e2e/skills_journey.py` and `parallel_llm_agent_journey.py`; +- examples and tests using core skill or parallel settings; +- `examples/web_research_report/agent.py` to remove obsolete core `read_paths` and `write_paths` while keeping its direct structured `ParallelLlmTool`; +- the rejected Bash child built-in test; +- public exports and changelog breaking entries, including direct `ParallelLlmTool` specs changing from kind `"parallel_llm"` to `"user"`. + +Do not replace direct `ParallelLlmTool` values that use a custom name or structured output. Do not wrap independent custom tools in plugins. + +## Success criteria + +- Skills and the default parallel batch tool are enabled only through explicit plugins. +- `PluginContext` contains only the canonical root and configured model. +- Core contains no direct skill or parallel-LLM imports, configuration, discovery, summary, path policy, model projection, or tool construction. +- Skill tools and summary are static, ordered, and based on one constructor-time catalog while discovered files remain live. +- Parallel plugin binding is I/O-free, and model ownership and provider-setting rules are explicit and tested. +- Low-level `SkillRegistry` and `ParallelLlmTool` behavior remains available without plugin composition. +- Main `builtin_tools` selects only `subagent`; the child built-in selector is gone and rejected loudly. +- Existing child tool inheritance remains safe through the exact temporary bridge. +- Focused suites, full suite, Ruff, Pyright, site drift check, and the three relevant end-to-end journeys pass. + +## Out of scope + +- `SubagentsPlugin`, child-harness factories, or generic plugin inheritance. +- Removing `HarnessConfig.builtin_tools`, `HarnessConfig.subagents`, or the main `subagent` built-in. +- Replacing `SkillRegistry` discovery or frontmatter parsing. +- Sandboxing skill scripts or changing supported runners. +- Dynamic skill catalog refresh or filesystem watchers. +- Nested tools, memory, or multi-turn sessions inside parallel LLM batches. +- Changing the low-level `ParallelLlmTool` structured-output interface. +- Provider, tracing, structured output, approval, resume, limit, hook, Bash, or direct custom-tool migration. +- Plugin discovery, package manifests, hot reload, dependency ordering, or plugin-to-plugin lookup. + +## Review record + +One Codex, Claude, and GLM panel round reviewed plan v1. Plan v2 applies the verified findings on I/O-free parallel binding, exact skill inheritance, frozen catalog versus live contents, removed-field rejection, model-setting validation, prompt order, fixed names, batch accounting, subagent validation, `ToolKind`, documentation coverage, summary wording, and focused tests. No second plan-review round is scheduled. diff --git a/.plans/40-subagents-plugin.md b/.plans/40-subagents-plugin.md new file mode 100644 index 0000000..876bcb7 --- /dev/null +++ b/.plans/40-subagents-plugin.md @@ -0,0 +1,490 @@ +# Subagents plugin — plan v2 + +Move delegation from the last built-in tool path to an explicit `SubagentsPlugin`. The plugin owns the `subagent` tool, child definitions, inheritance policy, child hooks, and delegation results. Core keeps only a narrow child-harness execution module that any trusted plugin can call without receiving the parent `Harness` object. + +This is a clean pre-1.0 break. Do not add aliases, fallback reads, deprecated paths, dual configuration, or migration constructors. + +## Resolved decisions + +1. **Delegation is explicit.** A harness gets delegation only from one `SubagentsPlugin`. Plain `Harness(...)` has no model-callable delegation tool. +2. **The unnamed child remains.** `SubagentsPlugin()` contributes `subagent`; omitting the model-visible `agent` argument selects the framework default child. Named children are optional. +3. **Children cannot delegate.** `SubagentsPlugin` never inherits into a child and is rejected in explicit child plugin configuration. Every child receives a disabled child-harness host that rejects before creating resources, so an inherited custom plugin cannot create a grandchild through `PluginContext`. A custom tool named `subagent` is not delegation and remains an ordinary tool. +4. **Inheritance rebinds plugins.** Replace `inherit_parent_tools` with additive `inherit_parent=True`. Safe parent plugins bind again against the child context rather than copying their parent-bound handlers. +5. **Safe plugin inheritance is explicit.** A plugin opts in through a structural `for_child() -> Plugin` method. `FilesystemPlugin`, `SkillsPlugin`, and `ParallelLlmPlugin` opt in and return themselves from a frozen constructor configuration. `MCPPlugin` and `SubagentsPlugin` do not opt in. A custom plugin without `for_child()` does not inherit. +6. **Parallel batches follow the child model.** Rebinding `ParallelLlmPlugin(model=None)` makes it borrow the child model. An explicit model object or model string keeps its configured model behavior. +7. **MCP is explicit in children.** Remove `SubAgentConfig.mcp_servers` and `inherit_mcp_servers`. A child that needs MCP includes `MCPPlugin(...)` in its own `plugins` list. Parent MCP connections never inherit implicitly. +8. **Inheritance is additive.** A named child may combine `inherit_parent=True` with explicit child `plugins` and `tools`. Inherited values come first. Duplicate plugin or tool names fail through normal atomic composition; explicit values do not silently replace inherited values. +9. **Child hooks stay with child configuration.** Named-child lifecycle hooks live in `SubAgentConfig.hooks`. Hooks for the unnamed child live in `SubagentsPlugin(default_hooks=...)`. Parent `before_subagent_run` and `after_subagent_run` hooks remain ordinary `Harness(..., hooks=...)` hooks. +10. **No special tool kind or reserved name remains.** Remove `ToolKind`, `ToolSpec.kind`, the `"subagent"` kind, and the core name reservation. A direct custom tool may use the name `subagent` when no plugin tool has that name. Normal duplicate-tool validation rejects a collision with `SubagentsPlugin`. +11. **Core does not receive subagent configuration.** Remove `HarnessConfig.builtin_tools`, `HarnessConfig.subagents`, and `Harness(subagent_hooks=...)`. Core does not import `SubAgentConfig`, `SubagentsPlugin`, or subagent tool construction helpers. +12. **Named children may have no tools.** A system-prompt-only or model-only named child is valid. Hooks alone do not expose tools, and no artificial tool-source requirement remains. +13. **Direct-tool inheritance follows run freezing.** The child sees the direct tools frozen for the active parent run. A direct tool added during that run is first available to a child in the next run. This is an intentional change from the current live-list behavior. +14. **Other execution behavior stays stable.** Child runs remain fresh, one level deep, independently budgeted, streamed and traced under the parent, and always closed. Model ownership, provider-setting projection, structured results, cancellation, errors, metadata, and before/after hooks keep their current meaning. + +## Target interface + +```python +from thinharness import ( + FilesystemPlugin, + Harness, + HarnessConfig, + Hook, + MCPPlugin, + MCPServerStdio, + SubAgentConfig, + SubagentsPlugin, +) + +research_mcp = MCPPlugin( + servers=[MCPServerStdio("python", ["research_server.py"])], +) + +harness = Harness( + HarnessConfig(model="openai:gpt-5.5"), + plugins=[ + FilesystemPlugin(tools=["read", "search"]), + SubagentsPlugin( + default_hooks=[ + Hook("run_start", prepare_default_child), + ], + agents=[ + SubAgentConfig( + name="researcher", + description="Research one focused question.", + system_prompt="Return concise findings with sources.", + model="anthropic:claude-opus-4-6", + inherit_parent=True, + plugins=[research_mcp], + tools=[custom_research_tool], + hooks=[ + Hook("run_start", prepare_researcher), + Hook("run_end", inspect_researcher_result), + ], + ), + ], + ), + ], + hooks=[ + Hook("before_subagent_run", approve_delegation), + Hook("after_subagent_run", record_delegation), + ], +) +``` + +`SubagentsPlugin` has the fixed runtime name `"subagents"` and contributes one tool named `subagent`. Its constructor is keyword-only: + +```python +SubagentsPlugin( + *, + agents: Sequence[SubAgentConfig] = (), + default_hooks: Sequence[Hook] | HookRegistry | None = None, +) +``` + +Reject unordered agent collections, duplicate names, the reserved config name `"default"`, invalid plugin values, and any child configuration that contains `SubagentsPlugin`. Perform the recursion check in `thinharness/plugins/subagents.py` by object type, not by plugin name in core. Reusing one plugin object across harnesses binds it to each harness's own child host, root, and model. + +The unnamed child intentionally keeps today's fixed parent-derived model, system prompt, limits, output behavior, and inheritance policy. `default_hooks` is its only plugin-level override. Do not add a second default-child configuration type in this slice. + +`SubAgentConfig` keeps: + +```python +SubAgentConfig( + *, + name: str, + description: str, + system_prompt: str = DEFAULT_SYSTEM_PROMPT, + inherit_parent: bool = False, + plugins: Sequence[Plugin] = (), + tools: Sequence[ToolSpec] = (), + hooks: Sequence[Hook] | HookRegistry | None = None, + model: str | None = None, + max_model_requests: int | None = None, + max_tool_calls: int | None = None, + output_type: Any | None = None, + output_mode: Literal["auto", "native", "tool", "prompted"] = "auto", + output_retries: int = 1, + tool_retries: int = 1, +) +``` + +Keep the current name and one-line description validation. Reject approval-required explicit tools because approval pauses remain unsupported inside children. Reject `SubagentsPlugin` in explicit child plugins by object type. Child hook collections must not contain `before_subagent_run` or `after_subagent_run` hooks and must not use `Hook.agents`; children cannot delegate, so these settings are invalid. Validate named and default child hooks when `SubagentsPlugin` is constructed, not when a child first runs. Remove `inherit_parent_tools`, `inherit_mcp_servers`, `mcp_servers`, and `builtin_tools`; reject these removed names with direct migration errors rather than accepting them. + +## Narrow child-harness host + +Do not put `Harness`, `HarnessConfig`, plugin lookup, mutable tool maps, tracing internals, or provider credentials into `PluginContext`. + +Add one narrow host capability to `PluginContext`: + +```python +@dataclass(frozen=True) +class PluginContext: + root: Path + model: Model + child_harnesses: ChildHarnessHost +``` + +Define the public `ChildHarnessHost` protocol and immutable request/outcome types in `thinharness/children.py`; export them from `thinharness/__init__.py` so third-party plugins can type and fake `PluginContext.child_harnesses`. Keep the parent-holding implementation private in the same neutral module. `thinharness/plugins/base.py` imports the host type only under `TYPE_CHECKING`, which avoids a core↔plugins runtime cycle. + +The request carries the plugin-owned delegation vocabulary as opaque data: agent name, agent description, trace agent name, task, `inherited`, `tool_mode`, child system prompt, model override, explicit plugins/tools/hooks, limits, output settings, and retry settings. Core does not construct `"subagent."` names or interpret the tool-mode values. The outcome carries the child result, effective tool names, structured-output serialization data, and any failure needed for the plugin's `ToolResult`. + +`tool_mode` has three stable values: + +- `"inherited"` for the default child or a named child with `inherit_parent=True` and no explicit plugins/tools; +- `"inherited+explicit"` for `inherit_parent=True` plus explicit plugins or tools; +- `"explicit"` for `inherit_parent=False`, including a model-only child. + +The host owns parent-dependent mechanics: + +- register a tool returned by its delegation interface in core-owned composition provenance; this internal role is not a `ToolSpec` field and survives generic plugin normalization; +- build one child harness from parent defaults plus explicit child overrides; +- obtain the active parent run's frozen direct-tool snapshot from core-owned tool runtime state, never from the live `Harness.tools` list; +- rebind child-inheritable plugins in parent plugin order; +- append explicit child plugins and direct tools in caller order; +- infer and own an override model, or borrow the parent model; +- project same-provider credentials and the existing request settings exactly as today; +- derive child tracing options and child hooks; +- connect the child, forward nested stream events, and preserve parent run and tool-call correlation; +- fire existing parent `before_subagent_run` and `after_subagent_run` hooks with the actual parent harness in their contexts; +- close the child after success, failure, or cancellation without hiding the original run error. + +A top-level host rejects a request made outside an active parent tool call before model inference, plugin connection, filesystem access, or child construction. Every child `PluginContext` receives a disabled host that always rejects before creating resources. This blocks grandchild creation even when an inherited custom plugin captures and calls its child context host. + +`SubagentsPlugin.bind()` captures only `context.child_harnesses` in its static tool handler and registers that returned tool through the host's delegation interface. Binding stays synchronous and I/O-free. Child model inference, plugin connection, filesystem access, and child execution happen only when the tool runs. + +Add contract coverage for all in-repo direct `PluginContext(...)` constructions in `tests/unit/test_plugins.py`, `tests/unit/test_parallel_llm.py`, and `tests/unit/test_skills.py`. Also prove that one plugin object bound to two parent harnesses receives independent child hosts and cannot cross parent roots, models, tools, hooks, metadata, or streams. + +## Child plugin inheritance + +Add and publicly export a runtime-checkable structural protocol without changing the base `Plugin.bind()` interface: + +```python +@runtime_checkable +class ChildInheritablePlugin(Protocol): + def for_child(self) -> Plugin: + """Return the plugin object to bind to one inherited child.""" +``` + +Rules: + +- `for_child()` is synchronous and performs no file, provider, or network I/O. +- `FilesystemPlugin.for_child()`, `SkillsPlugin.for_child()`, and `ParallelLlmPlugin.for_child()` return `self`. +- Make these three plugins use one private immutable constructor snapshot for every bind. Copy mutable inputs on construction, expose no mutable configuration container used by binding, and reject configuration assignment/deletion after construction. Mutation of an original input or a value obtained from a public property cannot change later parent or child bindings. Make `FilesystemPlugin.name` runtime-fixed like the other built-in plugin names so child rebinding cannot change identity. +- Returning `self` therefore reuses the same frozen configuration while normal child binding creates independent `ToolSpec` values and instructions. +- `SkillsPlugin` shares its constructor-time registry and frozen catalog while live skill files remain live. +- `ParallelLlmPlugin(model=None)` sees the child `PluginContext.model`; explicit plugin models keep their existing ownership rules. +- `MCPPlugin` has no `for_child()`. A child may still list the same MCP plugin object explicitly; normal binding and reference-counted server lifecycle apply. +- `SubagentsPlugin` has no `for_child()` and is also rejected if listed explicitly in a child's plugins. +- A custom plugin opts in only by implementing `for_child()`. It may return itself or a fresh configured plugin. Validate the returned object and fixed name before child construction. The custom plugin owns its snapshot and thread-safety contract. +- A child-inheritable plugin must not contribute approval-required tools. Reject known static violations while the parent harness is constructed; reject connected violations atomically when the child connects. Do not silently filter plugin tools or leave their instructions/hooks behind. +- Preserve parent plugin order among inherited plugins. Append explicit child plugins after inherited plugins. +- Duplicate names across inherited and explicit child plugins fail. Do not add implicit replacement, exclusion lists, clone fallbacks, or plugin-name special cases. `inherit_parent=True` cannot mean “inherit some and override one”; callers use `inherit_parent=False` and list the wanted plugins/tools explicitly. +- `SubagentsPlugin.bind()` registers each static child recipe with the child host. After all parent plugins bind, the host validates named-child plugin/tool collisions and known approval violations before `Harness(...)` returns. Revalidate direct-tool collisions after `Harness.add_tool()` and connected composition where the relevant names become known. Only genuinely dynamic child connection collisions may surface when the child connects. + +## Direct tool inheritance and run freezing + +`ToolOrigin` is caller-visible attribution metadata, not authoritative ownership. A direct caller may forge any origin value. Track tool ownership in a separate core-owned composition record that never comes from `ToolSpec.origin`. + +Track direct tools and plugin tools as explicit internal composition sources: + +- tools passed through `Harness(tools=...)` are direct; +- `Harness.add_tool()` adds a direct tool; +- tools contributed by plugins are not direct; +- freeze the eligible direct-tool list and authoritative plugin ownership with the run toolset; +- place that frozen composition snapshot in the existing core-owned tool runtime context before any tool handler starts, so the child host reads the active snapshot without inspecting live harness state; +- a direct tool added during a parent run does not enter a child delegated during that run, but it is available to children in the next run; +- a valid child-host request outside an active tool runtime fails clearly instead of falling back to current direct tools; +- inherited direct tools preserve parent order and object identity; +- exclude approval-required direct tools; +- append explicit child tools after inherited direct tools; +- reject duplicate tool names through normal child composition; +- do not filter by the string name `subagent`; the actual delegation tool comes from a non-inheritable plugin and is never in the direct source. + +The default child always uses `inherit_parent=True`. A named child uses its `inherit_parent` value. A named child with `inherit_parent=False`, no plugins, and no tools receives no model-callable tools and remains valid. + +## Plugin contribution and hook-filter names + +Core currently imports subagent configuration only to validate `Hook.agents` filters. Replace that feature-specific path with static plugin binding metadata. + +Extend `PluginBinding` with an immutable tuple of valid agent names, empty by default. `SubagentsPlugin` contributes `("default", *named_agent_names)` when it binds. + +Core requirements: + +- combine static agent names from every plugin binding before validating caller and plugin hooks; +- reject blank names and duplicate names within or across bindings; +- validate `Hook.agents` at harness construction, after the existing `Harness.add_tool()` revalidation point, and after connected contributions; +- do not add a public `Harness.add_hook()` method in this slice; +- connected plugins cannot change agent names; +- a harness without `SubagentsPlugin` rejects any agent-filtered hook because no agent names exist; +- dynamic agent catalogs are out of scope. + +Child lifecycle hooks from `SubAgentConfig.hooks` or `default_hooks` belong to the child harness. A supplied `HookRegistry` keeps its own `strict_hooks` value; a plain hook sequence uses the parent harness `strict_hooks` setting, matching current child behavior. Copy caller-owned hook registries before child composition. Parent subagent hooks remain in the parent registry and continue to support `agents=[...]` filters. + +## SubagentsPlugin behavior + +Preserve these model-visible and runtime behaviors: + +- tool arguments remain `task: str` and optional non-empty `agent: str`; +- omitting `agent` selects `"default"`; +- an unknown name returns a failed result with the requested name, sorted available names, and `UnknownSubAgent`; +- the description lists named agents in caller order and tells the model how to select the default; +- each child starts a fresh provider session with its own system prompt and no parent transcript; +- parent and child limits remain independent; +- the parent counts one `subagent` tool call; child requests and tokens stay in child usage and result metadata; +- override models inherit current timeout, retry, backoff, temperature, token, effort, and extra-body settings; only same-provider overrides receive parent API key and base URL; +- parent-model children borrow the model and never close it; override models are child-owned and close once; +- child structured output becomes the tool content and reports `structured_output=True`; +- successful metadata keeps agent, inheritance mode, effective tools, model requests, and structured-output status; +- cancellation and strict-hook failures do not hang sibling tool calls; +- child close runs after success, provider failure, hook failure, stream cancellation, or parent cancellation; +- child events remain flattened only when `StreamOptions.include_subagents=True`; +- conversation id and parent call id propagation remain unchanged; +- before-hook metadata mutation does not alter child metadata; +- plugin state, child configuration, child host, and models never enter resume or approval state. + +The generic plugin normalizer assigns `ToolOrigin(plugin="subagents", source="subagent")`. + +## Remove core built-ins and special tool state + +Remove from `HarnessConfig`: + +- `builtin_tools`; +- `subagents`. + +Add explicit removed-field guards in `thinharness/_migration.py` that point both names to plugin composition and name `SubagentsPlugin` for delegation. Delete `_select_builtin_tools()` and every built-in migration branch from core. A plain harness now has no implicit or selected built-in tool path. The changelog must state that old filesystem, skills, and parallel values inside `builtin_tools` now move to their respective plugins rather than treating the generic removed-field message as feature-specific guidance. + +Remove from `Harness`: + +- `subagent_hooks=`; +- `self.subagent_hooks`; +- direct imports of subagent configuration or tool builders; +- subagent-specific hook-filter lookup; +- the `subagent` reserved-name check. + +Keep an internal child-harness marker only where core needs it to enforce no approval-required tools inside children, install the disabled child host, and suppress top-level-only local tracing. Rename the marker and the approval error to say `child harnesses` rather than `subagents`. + +Remove from tool contracts: + +- `ToolKind`; +- `ToolSpec.kind`; +- runtime kind validation. + +All `ToolSpec` values then use one ordinary contract. Update direct construction, tests, exports, documentation, and changelog together. + +Delete the public low-level composition helpers `create_subagent_tool()` and `build_child_harness()` and remove their top-level exports. Do not leave wrappers or aliases. Move retained public types (`SubAgentConfig`, `SubAgentArgs`, and `DEFAULT_SUBAGENT_NAME`) behind the plugin module. Export them and `SubagentsPlugin` from both `thinharness/plugins/__init__.py` and `thinharness/__init__.py`. Export `ChildInheritablePlugin`, `ChildHarnessHost`, and the child request/outcome types through the same public plugin-contract surface. + +Delete `thinharness/subagents.py` when its retained implementation has moved behind `thinharness/plugins/subagents.py` and the narrow child host module. Do not keep a pass-through module. + +## Tracing and transcript classification + +Keep current public event names and result trace attributes for real delegation. Detect delegation from the core-owned frozen composition source, not from the tool name or caller-forgeable `ToolOrigin`: + +- when a tool span starts, look up the frozen authoritative composition role for that exact tool and set `subagent.delegation=true` before any hook or handler can cancel or fail; +- only a tool registered through the child host's delegation interface receives that marker; ordinary plugin origin metadata cannot create the role; +- `ToolOrigin` remains attribution metadata and never decides delegation control flow or transcript kind; +- a custom direct tool named `subagent`, including one with `ToolOrigin(plugin="subagents")`, receives ordinary tool tracing; +- keep `subagent.name`, `subagent.tool_mode`, and `subagent.tools` when a real delegation outcome supplies them; +- a cancelled or early-failing real delegation still has the marker even when result metadata is absent; +- keep child `invoke_agent subagent.` spans and agent descriptions; +- update `scripts/build_transcripts.py` to classify a span as a subagent only when `subagent.delegation` is true; +- do not add a legacy tool-name fallback for stored traces; update the checked-in web-research trace's known real delegation spans with the truthful marker, or regenerate that trace with the new implementation, before rebuilding transcript HTML; +- retain rendered transcript output for the checked-in real subagent trace; +- add regressions for a normal delegation, a before-tool-cancelled delegation, an early failure, and a forged-origin direct tool. + +`subagent.delegation` is the only new trace attribute. Do not rename existing public streaming events, hook events, trace attributes, stop reasons, or result metadata. + +## Behavior contract changes before implementation + +After plan review and before code changes, update only affected sections of `docs/behavior.md`: + +- extend PLUGIN-3 with the narrow child-harness host while keeping binding synchronous and I/O-free; +- extend the plugin binding contract with static agent names, authoritative core composition provenance, and explicit `for_child()` inheritance; +- update PLUGIN-8 for the `SubagentsPlugin` tool and child plugin ordering; +- update FILESYSTEM-PLUGIN requirements for a fixed plugin name and frozen constructor configuration; +- replace SKILLS-PLUGIN-7 with generic safe-plugin rebinding and shared registry behavior, and state that inheritable plugin configuration is frozen; +- update PARALLEL-LLM-PLUGIN-2 so an inherited borrowed-model plugin uses the child model and frozen plugin configuration; +- update PARALLEL-LLM-PLUGIN-7 to remove the obsolete ordinary `"user"` tool-kind statement; +- update PROVIDER-RETRY-6 for child model override and rebound parallel model settings; +- replace MCP-7's temporary bridge with explicit child `MCPPlugin` composition; +- update TOOLSET-FREEZE-1 through TOOLSET-FREEZE-3 so the same run snapshot carries direct-tool ownership used by delegated children; +- add a Subagents Plugin section covering explicit composition, the default child, named configs, additive inheritance, safe plugin rebinding, direct-tool freezing, child hooks, disabled child hosts, no recursion, model ownership, limits, streaming, tracing, results, cancellation, and state exclusion; +- remove every statement that calls subagent a built-in tool or documents `builtin_tools`. + +Do not change unrelated behavior sections. + +## Architecture guards + +Extend `tests/unit/test_architecture.py` with AST import and named-identifier checks rather than a blanket `"subagents" not in source` substring assertion. Direct `thinharness/core.py` source rejects: + +- imports of `subagents` or `plugins.subagents`; +- `SubAgentConfig`, `SubagentsPlugin`, `SubAgentArgs`, and `DEFAULT_SUBAGENT_NAME`; +- `create_subagent_tool`, `build_child_harness`, and `_select_builtin_tools`; +- `builtin_tools`, `subagent_hooks`, and direct subagent name reservation; +- filesystem, skills, parallel-LLM, or MCP inheritance logic. + +Add structural checks that: + +- `thinharness/subagents.py` no longer exists; +- core constructs only the narrow child host and does not expose the parent harness through `PluginContext`; +- `SubagentsPlugin` owns the delegation tool and configuration; +- only plugins with `for_child()` enter automatic child plugin composition; +- no name-based delegation detection remains in core or tool execution. + +## Tests + +Retain and migrate the existing subagent behavior suite. Add focused coverage for: + +### Plugin construction and composition + +- `SubagentsPlugin()` contributes one static `subagent` tool and the default agent name; +- no plugin means no delegation tool; +- fixed plugin name, origin, description, schema, order, and normal collisions; +- ordered agent validation, duplicate names, reserved `default`, invalid descriptions, and plugin reuse across harnesses with different roots and models; +- duplicate agent names within or across binding metadata fail; +- a named child with no tools is valid; +- a child cannot list `SubagentsPlugin` explicitly, and the error comes from plugin-module validation rather than core; +- plugin bind is I/O-free and does not infer a child model; +- all direct third-party-style `PluginContext(...)` constructions receive a fake child host cleanly; +- a top-level child host rejects outside an active tool runtime, and a child disabled host rejects an inherited custom plugin's grandchild attempt before resources open; +- plugin state and host references do not serialize into resume or approval state. + +### Inheritance + +- default and named `inherit_parent=True` children rebind filesystem, skills, and parallel plugins in parent order; +- skills reuse the exact registry and summary once; +- `ParallelLlmPlugin(model=None)` uses a named child's cross-provider override model without inheriting the parent API key or base URL; +- parallel plugins with explicit model objects or strings keep those models and ownership rules; +- MCP does not inherit, while an explicit child MCP plugin connects and closes through its own binding; +- a custom plugin with `for_child()` inherits; one without it does not; +- an invalid `for_child()` return fails clearly; +- mutation attempts and mutable constructor inputs cannot change later filesystem, skills, or parallel child bindings; +- inherited and explicit plugins/tools are additive and preserve their stated order; +- a known inherited/explicit filesystem or direct-tool collision fails while the parent is constructed, and later `add_tool()` collisions revalidate before a run; +- dynamic child connection collisions fail atomically; +- `inherit_parent=True` plus explicit sources reports `tool_mode="inherited+explicit"` in hooks, results, and traces; +- plugin tools are not copied again as direct tools; +- direct tools with caller-supplied or forged origins still inherit and trace as direct tools; +- approval-required direct and explicit tools never enter children; +- an inheritable plugin with a known approval-required tool fails parent construction rather than being silently filtered; +- a direct tool added during a run appears only in children of the next run. + +### Hooks and execution + +- named hooks come from `SubAgentConfig.hooks`; default hooks come from `default_hooks`; +- child hooks with subagent events or any `agents=` filter fail at plugin construction; +- caller-owned `HookRegistry` values are copied; registry strictness is preserved, while plain sequences use parent strictness; +- parent before/after hooks, cancellation, metadata isolation, and agent filters remain stable; +- a default child works with `default_hooks` and parent hooks filtered to `agents=["default"]`; +- agent-filtered parent hooks fail without the plugin and for unknown names; +- default, unknown, blank, model-only, structured-output, provider-failure, and child-close paths; +- shared parent model versus owned override model lifecycle; +- two concurrent delegations to one override-model config create two owned child providers and close each once; +- same-provider credential forwarding and cross-provider credential isolation; +- fresh budgets, retries, notices, metadata, and usage accounting; +- concurrent delegation and strict sibling abort do not hang; +- nested streaming correlation and `include_subagents` behavior; +- tracing attributes and child spans remain stable; +- `subagent.delegation=true` exists from span start for normal, cancelled, and early-failing real delegation; +- a direct tool named `subagent` with a forged `ToolOrigin(plugin="subagents")` remains an ordinary trace and transcript event. + +### Removal + +- each removed `HarnessConfig` field raises its own `SubagentsPlugin` migration error; +- removed `SubAgentConfig` fields fail by name; +- `Harness(subagent_hooks=...)`, `create_subagent_tool`, and `build_child_harness` are gone; +- `ToolSpec` has no `kind` field and no `ToolKind` remains; +- a direct custom tool named `subagent`, even with forged subagents origin metadata, works without the plugin and has ordinary tracing; +- the same custom tool collides normally when the plugin is present; +- core has no feature-specific subagent composition. + +## Caller, documentation, and site migration + +Update every checked-in caller. This includes: + +- `README.md` feature text and examples; +- `docs/docs.md` configuration, hooks, subagents, MCP, tracing, and plugin examples, including the tool-surface sentence, rejected Bash example, and SkillsPlugin child wording; +- `docs/site/explainer/index.html` architecture and composition text; +- `docs/site/about/index.html` through `scripts/build_site.py`; +- the checked-in web-research trace marker and `docs/site/examples/index.html` through transcript regeneration; +- `CHANGELOG.md` with all breaking removals and inheritance changes; +- `examples/web_research_report/agent.py`, including required-tool and trace-name assertions; +- `examples/mcp_plugin.py`; +- `tests/e2e/langfuse_tracing_journey.py`; +- `tests/unit/test_harness.py` built-in migration tests and the removed add-after-construction delegation capability; +- every test and journey that passes `builtin_tools=[]` or `builtin_tools=["subagent"]`; +- every import of `create_subagent_tool()` or `build_child_harness()`; +- every direct `PluginContext(...)` constructor affected by the required host field. + +Delete `builtin_tools=[]` rather than replacing it: no built-in tools remain. Replace enabled delegation with `plugins=[..., SubagentsPlugin(...)]` in caller order. + +Add `tests/e2e/subagents_journey.py` with real provider calls. It must prove that the plugin delegates to the default child and to a named override-model child, returns child output to the parent, prevents child delegation, and emits the expected metadata without relying only on mocks. + +## Implementation steps + +1. Update the affected behavior contracts. +2. Add the narrow child-harness host and its request/outcome types without moving feature configuration into core. +3. Extend `PluginContext` and `PluginBinding` with the child host and static agent names. +4. Add `ChildInheritablePlugin` and implement `for_child()` on filesystem, skills, and parallel plugins. +5. Add `SubagentsPlugin`, move the retained public configuration types behind it, and implement static tool binding. +6. Move child construction, execution, hooks, streaming, tracing, and cleanup behind the narrow host. +7. Implement explicit direct-tool source tracking and run-frozen child inheritance. +8. Implement additive inherited and explicit child composition with normal collision errors. +9. Remove MCP and skills/filesystem temporary bridges and the parent-bound parallel handler path. +10. Migrate child hooks to `SubAgentConfig.hooks` and `default_hooks`; replace core agent-filter lookup with binding metadata. +11. Remove built-in selection, old config fields, constructor arguments, public helpers, `ToolKind`, `ToolSpec.kind`, and reserved-name logic. +12. Change tracing and transcript classification to use core-owned composition provenance and the start-of-span delegation marker. +13. Migrate unit tests, examples, documentation, site pages, and all end-to-end journeys. +14. Delete the obsolete `thinharness/subagents.py` module and add architecture guards. +15. Regenerate checked-in generated pages and run all validation. + +## Validation + +Run: + +```bash +uv run pytest tests/unit/test_subagents.py tests/unit/test_plugins.py tests/unit/test_harness.py tests/unit/test_hooks.py tests/unit/test_architecture.py +uv run pytest tests/unit/test_streaming.py tests/unit/test_tracing.py tests/unit/test_approvals.py tests/unit/test_structured_output.py tests/unit/test_tool_retry.py tests/unit/test_mcp.py tests/unit/test_parallel_llm.py +uv run pytest tests/unit/test_web_research_report_example.py +uv run pytest +uv run ruff check . +uv run pyright +uv run scripts/build_site.py +uv run scripts/build_site.py --check +uv run scripts/build_transcripts.py +for journey in tests/e2e/*_journey.py; do uv run --env-file .env python "$journey"; done +git diff --check +``` + +Keep the migrated suite at `tests/unit/test_subagents.py`; do not rename it. Report every journey separately. Credential- or service-based skips are not passes. + +## Success criteria + +- ThinHarness has no built-in tool selector or implicit feature tool path. +- Delegation exists only through explicit `SubagentsPlugin` composition. +- The plugin owns child definitions, the default child, tool description, inheritance choices, child hooks, and result shaping. +- Core does not import subagent configuration or plugin implementation and exposes only a narrow child-harness host. +- Children receive a disabled child host and cannot create grandchildren through built-in or custom inherited plugins, but a custom ordinary tool may use the same model-facing name. +- Safe parent plugins rebind against the child; unknown custom plugins do not inherit without `for_child()`. +- Inherited parallel LLM batches borrow the child model when configured with `model=None`. +- Parent MCP never inherits implicitly; explicit child MCP keeps independent binding and cleanup. +- Additive inherited and explicit composition is ordered and collision-safe. +- Child inheritance uses the active parent run's frozen direct tools. +- Child hooks are local to each named config or the plugin default. +- Streaming, tracing, hooks, usage, limits, model ownership, structured output, errors, and cancellation retain their current behavior except for the approved child-model parallel rebinding and run-frozen direct-tool inheritance changes. +- Core, docs, examples, tests, and site pages contain no obsolete subagent built-in configuration. +- Focused suites, the full suite, Ruff, Pyright, generated-page checks, and every live end-to-end journey pass. + +## Out of scope + +- Nested or recursive delegation. +- Forking the parent conversation into a child. +- Background children, detached jobs, scheduling, or persistent child sessions. +- Child approval pauses. +- Dynamic agent catalogs or plugin discovery. +- Automatic inheritance of MCP or unknown custom plugins. +- Selective inherited-plugin exclusion, implicit override by name, or dependency resolution. +- Changing child result shape, stream event names, trace attribute names, hook event names, provider retry policy, or structured-output semantics. +- Compatibility aliases, deprecation periods, config migrations, or fallback reads. + +## Review record + +One Codex, Claude, and GLM panel round reviewed plan v1. Plan v2 applies the verified findings on disabled child hosts, authoritative tool provenance, start-of-span delegation marking, immutable inheritable plugin configuration, approval-tool policy, eager collision validation, child-hook validation, child request fields, tool-mode values, public host types, direct `PluginContext` callers, run-frozen tool plumbing, behavior-contract coverage, AST-scoped architecture guards, transcript regressions, and caller migration. No second plan-review round is scheduled. diff --git a/.plans/41-bash-plugin.md b/.plans/41-bash-plugin.md new file mode 100644 index 0000000..e9fb311 --- /dev/null +++ b/.plans/41-bash-plugin.md @@ -0,0 +1,339 @@ +# Bash plugin — plan v2 + +Replace the directly registered `BashTool` with an explicit `BashPlugin`. The plugin provides one bounded, non-interactive Bash command tool. It uses the canonical harness root and does not inherit into child harnesses. + +This is a clean pre-1.0 break. Do not keep compatibility exports, aliases, fallback registration, or two public Bash interfaces. + +## Resolved decisions + +1. **Bash is an explicit plugin.** A plain harness has no Bash tool. Callers enable it with `plugins=[BashPlugin()]`. +2. **Bash stays one-shot.** Every call starts a fresh `bash -c` process. Shell variables, functions, aliases, and `cd` do not persist between calls. +3. **The tool is non-interactive.** It has no stdin, PTY, background mode, job tools, or live output stream. +4. **The plugin uses the canonical root.** `BashPlugin` receives `PluginContext.root`; callers cannot configure a second root. A model-selected cwd must resolve under that root. +5. **Cancellation stops the command.** Cancelling the task that awaits `Harness.run()` signals the command's process group, completes bounded cleanup after process handoff despite repeated cancellation requests, and then propagates cancellation. Cancellation during process creation waits for handoff to settle so no process can be orphaned. +6. **Timeouts stop the process group.** The effective timeout covers shell execution after process creation. Process creation is outside this timeout. Timeout sends `SIGTERM`, waits one second, then sends `SIGKILL` if needed. Fixed cleanup periods can make wall-clock time exceed `timeout_seconds`. +7. **Background cleanup is best effort.** After the direct shell exits, the plugin terminates remaining processes in the captured process group before it returns. A process that creates a new session can escape this cleanup, and the operating system can reuse a process-group ID after the shell exits. +8. **Output is bounded while the command runs.** The implementation reads stdout and stderr concurrently in fixed-size chunks into separate bounded head-and-tail buffers. It does not use line reads or write full output to temporary spill files. +9. **The environment is minimal by default.** The process inherits a small host allowlist plus non-interactive defaults. Full environment inheritance is an explicit host choice. Model arguments cannot set environment variables. +10. **The host controls limits.** Plugin configuration owns default timeout, maximum timeout, and per-stream output bytes. The model may request a timeout but cannot exceed the plugin maximum. +11. **Bash is sequential.** A tool batch containing Bash runs in model order without overlapping sibling calls. +12. **Child access is explicit.** `BashPlugin` does not implement `for_child()`. A child that needs Bash must list its own `BashPlugin`, and approval-required Bash remains invalid in children under existing child rules. +13. **Approval is optional.** `BashPlugin(requires_approval=True)` uses the existing top-level approval pause and resume flow. Approval is not a shell-specific prompt or UI. +14. **Local Bash is not a sandbox.** Cwd containment and environment filtering do not restrict absolute paths, network access, host credentials stored in files, or other host authority. +15. **No executor interface yet.** Keep local process execution private until ThinHarness has a real second executor such as a container or remote sandbox. +16. **POSIX only.** The plugin runs `bash` from `PATH` and depends on POSIX process groups. It does not discover Windows Bash installations or fall back to another shell. + +## Public interface + +Add `thinharness/plugins/bash.py` and export `BashPlugin` from `thinharness.plugins` and `thinharness`: + +```python +from thinharness import BashPlugin, Harness, HarnessConfig + +harness = Harness( + HarnessConfig(root="."), + plugins=[BashPlugin()], +) +``` + +The constructor is keyword-only: + +```python +BashPlugin( + *, + default_timeout: float = 30, + max_timeout: float = 120, + max_output_bytes: int = 40_000, + inherit_env: bool = False, + env: Mapping[str, str] | None = None, + requires_approval: bool = False, +) +``` + +Validate configuration when the plugin is constructed: + +- `default_timeout` and `max_timeout` must be real `int` or `float` values, not booleans, and must be finite and greater than zero; +- `default_timeout` must not exceed `max_timeout`; +- `max_output_bytes` must be an `int`, not a boolean, and must be greater than zero; +- `inherit_env` and `requires_approval` must be real booleans; +- every environment name and value must be a string; +- environment names must be non-empty and contain neither `=` nor NUL, and values must not contain NUL; +- copy `env` so later caller mutation cannot change a binding. + +The plugin name is fixed to `"bash"`. Its configuration is frozen after construction, matching the other first-party plugins. `bind()` performs no filesystem or process I/O and returns one static `ToolSpec` with `ToolOrigin(plugin="bash", source="bash")` after normal plugin composition. The contribution adds no plugin instructions; the tool description contains the model-facing execution rules. + +Remove `BashTool` and `BashArgs` from `thinharness.tools` and top-level exports. The replacement argument model is private to `thinharness.plugins.bash`; `BashPlugin` is the only public Bash interface. Remove the public direct-registration form. Keep all process helpers inside `thinharness/plugins/bash.py`; the existing synchronous process users do not justify a shared executor seam. + +## Model-facing tool + +The tool name is `bash`. Use a private Pydantic argument model based on `StrictArgs`. Here, strict means both `extra="forbid"` and no value coercion: `command` and `cwd` accept only strings, while `timeout` accepts only real integers or floats, rejects booleans and non-finite values, and must be greater than zero. + +```python +class _BashArgs(StrictArgs): + command: str = Field(min_length=1, strict=True) + cwd: str = Field(default=".", strict=True) + timeout: float | None = None +``` + +Use field validators for the numeric rules and for `command`. Reject a command that contains only whitespace during Pydantic validation so the model receives the existing retryable validation result. Do not strip or otherwise change a valid command. + +The tool description must state: + +- it runs one non-interactive Bash command; +- cwd must be inside the workspace; +- calls do not share shell state; +- same-process-group background processes are terminated on a best-effort basis; +- `timeout` is in seconds and is capped by the host. + +Do not expose output limits, environment variables, stdin, shell selection, login mode, background mode, sandbox permissions, or approval in the model schema. + +If the model omits `timeout`, use `default_timeout`. If it requests more than `max_timeout`, clamp the effective timeout to `max_timeout` and report the effective value in result metadata. + +## Process execution + +Use a native async handler and `asyncio.create_subprocess_exec()` rather than a synchronous handler in `asyncio.to_thread()`. The runner owns the stdout and stderr pipe read ends and their asyncio read transports instead of relying on `Process.stdout` and `Process.stderr`. Pass the corresponding write descriptors to `create_subprocess_exec()`. This ownership lets cleanup close the parent read transports when an escaped descendant keeps a write end open. + +Execution rules: + +- reject unsupported platforms before creating pipes or spawning, through a private capability check that requires `os.name == "posix"` and `os.killpg`; return `UnsupportedPlatform` otherwise; +- resolve cwd under `PluginContext.root` and require an existing directory; +- call `bash -c` with the resolved cwd, filtered environment, `stdin=DEVNULL`, owned stdout and stderr pipe write descriptors, and `start_new_session=True`; +- return a structured `ProcessStartError` if Bash or the process cannot start; +- close every pipe descriptor if pipe setup or process creation fails; +- make process creation a cancellation-safe handoff: keep the spawn task referenced, and if cancellation arrives during `create_subprocess_exec()`, tolerate repeated cancellation while waiting for the spawn task to settle; if it returns a process, use `process.pid` as the process-group ID created by `start_new_session=True`, signal it, complete bounded cleanup, and then propagate cancellation; never leave a spawn or cleanup task detached; +- after handoff, capture `process.pid` as the process-group ID and start one reader task for stdout and one for stderr before waiting for process completion; +- readers use fixed-size `read(n)` chunks, never line-oriented reads; +- the effective timeout starts after process handoff and covers waiting for the direct shell; +- on normal shell exit, attempt to terminate remaining members of the captured process group, then run bounded final drain; +- on timeout, terminate the captured process group, then run bounded final drain and return a timeout result; +- on `asyncio.CancelledError`, synchronously send the first group signal, then run one referenced cleanup task through a re-cancellation-tolerant await loop with a fixed deadline; after cleanup, re-raise cancellation; +- group termination sends `SIGTERM`, waits one second, then sends `SIGKILL` if the group still exists; +- final drain waits up to one second for both readers, then closes both parent read transports, cancels and joins both readers, and returns the partial output; +- close and join every owned transport, descriptor, reader task, spawn task, and cleanup task on every exit path; +- do not expose spawn, termination, or drain cleanup periods as public configuration in this slice. + +Process-group cleanup is best effort. It cannot stop a descendant that deliberately creates a new session or otherwise leaves the process group. Signalling a captured group after the direct shell has been reaped also has an unavoidable process-group-ID reuse race. State both limits without calling the tool isolated or promising that all descendants are stopped. + +## Environment policy + +When `inherit_env=False`, copy only these values when present: + +- `PATH`; +- `HOME`; +- `TMPDIR`, `TMP`, and `TEMP`; +- `LANG`, `LC_ALL`, and `LC_CTYPE`; +- `TZ`. + +When `inherit_env=True`, start from a copy of `os.environ`. + +For both modes: + +1. remove inherited `BASH_ENV` and `ENV`; +2. set `NO_COLOR=1`, `TERM=dumb`, `PAGER=cat`, and `GIT_PAGER=cat`; +3. apply the explicit host `env` mapping last, so the host can deliberately replace any value. + +This policy reduces accidental environment-secret exposure. It is not a security boundary because commands can still read host files and use other credential sources. + +## Output and results + +Keep stdout and stderr separate. Each stream gets its own `max_output_bytes` budget. + +The private buffer must: + +- retain `ceil(max_output_bytes / 2)` bytes from the start and `floor(max_output_bytes / 2)` bytes from the end; +- when `max_output_bytes == 1`, retain the first byte and no tail; +- discard the middle after the cap is reached; +- keep retained stream bytes at or below `max_output_bytes`, not total command output; +- continue draining discarded bytes so the child cannot block on a full pipe; +- decode UTF-8 with replacement after collection; +- for truncated output, insert `\n... {omitted_bytes} bytes omitted ...\n` between the decoded head and tail; the rendered marker does not count against the retained-byte budget; +- return whether truncation occurred and the total bytes seen. + +Return model content with labelled `stdout` and `stderr` sections. Preserve partial output for non-zero exits and timeouts. If both streams are empty, return only `(no output)`. If one stream is empty, keep both labelled sections and put `(no output)` in the empty section. + +Result metadata contains: + +- `exit_code`; +- `signal` when the return code represents a signal; +- `timed_out`; +- `duration_seconds`, measured from the start of spawn handoff through final cleanup; +- resolved `cwd`; +- effective `timeout_seconds`, which covers direct-shell execution after spawn handoff and does not include fixed cleanup periods; +- `stdout_bytes` and `stderr_bytes`; +- `stdout_truncated` and `stderr_truncated`. + +Result rules: + +- exit code zero returns `ok=True`; +- a non-zero exit returns `ok=False` with `error_type="NonZeroExit"`; +- timeout returns `ok=False` with `error_type="Timeout"`; +- cwd, platform, and startup failures return their specific error type; +- command outcomes preserve their output and do not request a model retry; +- argument validation keeps the existing retryable validation behavior; +- run cancellation propagates and does not become a tool result. + +## Behavior contract changes before implementation + +After plan review and before implementation, add a Bash Plugin section to `docs/behavior.md` that records: + +- explicit plugin composition and no implicit Bash; +- the fixed plugin and tool names; +- canonical root and contained cwd; +- fresh non-interactive calls with no supported background persistence and the documented best-effort cleanup limits; +- timeout, run cancellation, best-effort process-group cleanup, bounded final drain, and cancellation propagation; +- bounded separate head-and-tail output with the fixed split and truncation marker and no spill files; +- minimal versus inherited environment policy; +- sequential execution; +- optional top-level approval; +- no automatic child inheritance; +- POSIX-only support and no shell fallback; +- the fact that local Bash is not a sandbox. + +Update only the affected plugin and Bash behavior. Do not change unrelated sections. + +## Implementation steps + +1. Update the affected behavior contract after plan review. +2. Add the frozen `BashPlugin` configuration and static binding. +3. Replace the synchronous subprocess implementation with the private async process runner, owned pipes, and cancellation-safe spawn handoff. +4. Add bounded per-stream head-and-tail collection and bounded final drain. +5. Add process-group timeout, repeated-cancellation-safe cleanup, normal-exit descendant cleanup, and environment policy. +6. Move Bash registration to the plugin and remove the public `BashTool` interface and exports. +7. Replace `tests/unit/test_bash_tool.py` with plugin, process, output, environment, approval, and inheritance tests. +8. Add architecture guards that keep Bash construction out of core and prevent `BashPlugin` from implementing child inheritance. +9. Update README, reference docs, generated site content, examples, and changelog. +10. Add a live Bash plugin journey and run all validation. + +## Tests + +Add focused coverage for: + +### Plugin composition + +- no plugin means no Bash tool; +- `BashPlugin()` contributes one static sequential tool with fixed name and origin; +- binding uses the canonical root and does not create it; +- invalid or mutable constructor values cannot change later bindings; +- string and integer Boolean options, Boolean numeric limits, fractional output limits, and invalid environment entries fail at construction; +- normal duplicate plugin and tool collisions fail; +- direct `BashTool` and `BashArgs` imports and exports are gone; +- the plugin does not inherit into default or named children; +- an explicit child `BashPlugin` works when approval is off; +- approval-required Bash works at the top level, requires a resumable top-level model at harness construction, and fails under existing child approval rules. + +### Process lifecycle + +- success, non-zero exit, signal exit, timeout, and startup failure; +- timeout escalates from TERM to KILL when the command ignores TERM; +- cancelling an active harness run kills the process group and propagates cancellation promptly; +- cancelling a second time during cleanup does not detach cleanup or leave the process group running; +- cancellation paused during process creation still completes spawn handoff, group cleanup, and cancellation propagation; +- normal shell exit cleans up a same-group background descendant; +- a new-session descendant that holds an output pipe cannot keep normal exit, timeout, or cancellation from returning after bounded drain; +- stdout and stderr are drained concurrently with chunk reads, including simultaneous floods larger than 64 KiB with no newlines; +- unsupported platforms fail before pipe creation or spawn through the private capability-check seam; +- missing and non-directory cwd values fail cleanly; +- cwd cannot escape root through absolute paths, `..`, or symlinks. + +### Output + +- small stdout and stderr remain separate and unchanged; +- large streams use the defined head/tail split and marker, report total bytes, and keep the marker outside the retained-byte budget; +- one-byte and odd output limits follow the defined split; +- output floods stay within the configured retained-memory bound and create no spill file; +- split multibyte and invalid UTF-8 decode with replacement; +- timeout and non-zero results keep partial output; +- empty output returns `(no output)`. + +### Environment + +- minimal mode keeps only the named host values plus non-interactive defaults; +- full inheritance is explicit; +- explicit host values override inherited and default values; +- provider API keys are absent in minimal mode; +- inherited `BASH_ENV` and `ENV` are not sourced unless the host explicitly supplies them; +- model arguments cannot provide environment variables; +- string and Boolean model values do not coerce into `timeout`, and whitespace-only commands produce retryable validation results. + +### Limits and integration + +- default timeout, per-call timeout, and maximum clamping; +- invalid plugin limits fail during construction; +- Bash keeps mixed tool batches sequential; +- hooks, tracing, tool-call records, and structured result envelopes use the normal plugin tool path; +- the end-to-end journey uses `BashPlugin` to run a bounded command and verifies that the parent receives its output. + +## Documentation and caller migration + +Update: + +- `README.md` opinion, feature list, and usage example; +- `docs/docs.md` plugin and Bash sections; +- the hand-written `docs/site/index.html` Bash text; +- README-derived `docs/site/about/index.html` through `scripts/build_site.py`; +- `scripts/build_site.py` opinion-tag mapping if the `No bash by default` heading changes; +- `tests/e2e/README.md` with the new journey; +- `CHANGELOG.md` with the breaking removal of public `BashTool` and `BashArgs` and the new environment, cancellation, and output behavior; +- top-level and plugin exports; +- every tracked test, example, and document that uses `BashTool(...).spec()`. + +`READMEV2.md` is an untracked working draft, not a product document for this slice. Do not modify or add it as part of the implementation. `docs/site/explainer/index.html` has no Bash-specific text and is also out of scope unless the implementation changes a statement in that file. + +Use this replacement: + +```python +# Remove +tools=[BashTool(root=".").spec()] + +# Add +plugins=[BashPlugin()] +``` + +Do not add a compatibility wrapper or deprecation period. + +## Validation + +Run: + +```bash +uv run pytest tests/unit/test_bash_plugin.py tests/unit/test_plugins.py tests/unit/test_harness.py tests/unit/test_subagents.py tests/unit/test_approvals.py tests/unit/test_parallel_tools.py tests/unit/test_architecture.py +uv run pytest tests/unit/test_streaming.py tests/unit/test_tracing.py tests/unit/test_tool_retry.py +uv run pytest +uv run ruff check . +uv run pyright +uv run scripts/build_site.py +uv run scripts/build_site.py --check +uv run --env-file .env python tests/e2e/bash_plugin_journey.py +git diff --check +``` + +A credential-based end-to-end skip is not a pass. Report it separately. + +## Success criteria + +- Bash is available only through explicit `BashPlugin` composition. +- Core does not import or construct Bash behavior. +- The plugin uses the canonical root and does not create it during construction or binding. +- Timeout and run cancellation perform bounded best-effort process-group cleanup; repeated cancellation does not detach cleanup. +- Normal exit, timeout, and cancellation return after bounded final drain even when an escaped descendant holds a pipe open. +- Output memory stays bounded while stdout and stderr are drained with chunk reads. +- Minimal environment mode does not expose unrelated host environment variables. +- Bash remains one-shot, non-interactive, sequential, and unavailable to children unless configured there explicitly. +- No background-job interface, persistent shell, PTY, output stream, spill file, shell fallback, command policy, sandbox, or public executor interface is added. +- Focused tests, the full suite, Ruff, Pyright, generated-site checks, and the live journey pass. + +## Out of scope + +- Background command management. +- Persistent shell state or cwd. +- PTYs, interactive programs, or stdin. +- Live tool-output streaming. +- Windows shell support or shell discovery. +- Command allowlists or denylists. +- Filesystem, network, syscall, or credential sandboxing. +- Remote, container, VM, or provider-native execution. +- Model-selected environment, shell, output limits, or sandbox permissions. +- Full-output spill files. +- Automatic child inheritance. +- A public process or executor interface. +- Compatibility aliases or migrations. diff --git a/.plans/42-image-inputs.md b/.plans/42-image-inputs.md new file mode 100644 index 0000000..9e8ed74 --- /dev/null +++ b/.plans/42-image-inputs.md @@ -0,0 +1,389 @@ +# ThinHarness image support plan + +Status: Revised after review panel v1. No implementation is authorized by this plan. + +## Goal + +Add first-class image input without expanding ThinHarness into a general media runtime. + +ThinHarness will support ordered text and image content in: + +- user prompts +- custom tool results +- the filesystem plugin through an opt-in image-reading tool +- successful MCP tool results + +OpenAI, Anthropic, and OpenRouter models will receive the same neutral content through provider-specific wire formats. Resume remains self-contained and portable across built-in providers. + +## Non-goals + +- image generation or assistant-produced images +- audio, video, files, or realtime voice +- remote image URLs or URL fetching +- image editing, resizing, OCR, or format conversion +- automatic image loading from Bash output +- model-name tables that predict vision support +- token-by-token streaming +- image-bearing subagent tasks or `parallel_llm` prompts; child task inputs stay text-only + +## Product decisions + +### One neutral content seam + +Add a small leaf module, `thinharness/content.py`, with immutable content blocks: + +```python +@dataclass(frozen=True) +class TextBlock: + text: str + +@dataclass(frozen=True) +class ImageBlock: + data: bytes + media_type: Literal[ + "image/jpeg", + "image/png", + "image/gif", + "image/webp", + ] + +ContentBlock = TextBlock | ImageBlock +Prompt = str | Sequence[ContentBlock] +``` + +Normalize a string prompt to one `TextBlock` at the public interface. Special-case `str` before sequence handling. Copy caller sequences to tuples on entry and preserve block order. + +Use immutable `bytes` plus an explicit media type as the only public image form. Do not accept `bytearray`, `memoryview`, paths, URLs, or provider-native image dictionaries. Provider adapters encode base64. Give `ImageBlock` a redacted `repr` that never prints image data. + +Reject empty string prompts, empty block sequences, empty text blocks, empty image data, unsupported media types, and unknown block values before the first provider request. Empty strings are accepted today, so this is an explicit behavior change. Do not inspect model names for vision support. A provider or custom model reports that a selected model cannot process images. + +### Prompt behavior + +`Harness.run()`, `stream()`, and `run_sync()` accept `Prompt`. Approval resume does not accept new user content. + +`RunStartContext.prompt` and `UserPromptSubmitContext.prompt` change from `str` to normalized content-block tuples. A hook may replace either field with a string or a valid block sequence; the harness normalizes and validates the replacement after each hook phase. `additional_context` stays `list[str]` and appends one final `TextBlock`. Harness notices remain separate notice transcript entries and append after hook context. The order sent to a provider is: + +1. caller blocks +2. hook context +3. harness notices + +At the provider boundary, an all-text user entry joins its blocks with `\n\n`. This keeps the existing string payload for a normal string prompt plus hook context. A mixed image entry preserves every block boundary and order. + +Rename the public custom-session method `continue_with_user_text` to `continue_with_user_content`. Update all unit and end-to-end fakes. This is a pre-1.0 interface change and avoids keeping a misleading name. + +### Tool-result behavior + +Allow `ToolResult.content` to be either a string or an ordered sequence of `ContentBlock` values. A bare block sequence returned by a handler is also accepted and becomes `ToolResult(ok=True, content=...)`. Normalize before provider serialization. Existing text-only tools continue to expose string content to callers. + +A normalized multimodal tool result keeps: + +- `ok` as the execution outcome +- ordered text and image content +- JSON metadata, including retry guidance, visible to the model + +Keep the current text-only provider output byte-for-byte: one JSON string with `ok`, string `content`, and `metadata`. For an image-bearing result, OpenAI and Anthropic receive a content array with: + +1. one text block containing compact JSON for `{"ok": ..., "metadata": ...}` +2. each `ToolResult.content` block in its original order, mapped to a provider text or image block + +The first block is the multimodal envelope header. It keeps execution state and retry metadata visible without putting image bytes in JSON text. Provider output preserves the tool-call ID and block order. + +`ToolOutput`, tool-call records, and v4 resume entries carry the normalized `ToolResult`, not only a serialized output string. The canonical JSON codec represents image data exactly once. + +Keep `AfterToolCallContext.original_output` and `output` as canonical JSON strings, including base64 image fields, and keep `envelope` as the structured mutation interface. Mutating either `output` or `envelope` updates the other and runs strict canonical validation. Document these hook fields as sensitive and potentially large. Remove every `json.dumps(..., default=str)` path that could stringify an unknown block. + +`BashPlugin` remains text-only. A command that prints an image path returns that path as text. Bash never reads or infers an image. + +### Provider mappings + +Keep existing text-only payload shapes unchanged. Use multimodal arrays only when content contains an image. + +#### OpenAI Responses + +- User text maps to `input_text`. +- User images map to `input_image` with a base64 data URL. +- Image-bearing tool results use the documented `function_call_output.output` content array. The envelope header and tool text map to `input_text`; images map to `input_image`. +- This array support is confirmed by the official [OpenAI function-calling guide](https://developers.openai.com/api/docs/guides/function-calling). Keep one live contract journey because SDK and model behavior can still differ. + +#### Anthropic Messages + +- Text maps to a `text` block. +- Images map to base64 `image` source blocks. +- Tool-result text and images stay inside the matching `tool_result.content` block list. + +#### OpenRouter Chat Completions + +OpenRouter documentation is not consistent about image parts in `role="tool"` messages. Use a portable follow-up user-message projection instead of depending on that shape: + +- User text and images map to `text` and `image_url` content parts. +- Each normal tool message keeps its `tool_call_id` and contains one canonical JSON string. For image-bearing content, image data becomes a descriptor with media type, byte size, and block index; `ok`, text content, and metadata remain in this tool message. +- After all matching tool messages in a parallel batch, add one `role="user"` message. For each tool image in tool-result order, add a label text part, `[tool image call_id= block=]`, immediately followed by its `image_url` part. +- The labelled user message is a provider wire projection only. The durable transcript keeps the neutral tool result, so live and resumed rendering use the same rule. +- A harness notice follows the labelled image parts in the same user message. + +The OpenRouter live journey must verify this projection with a current vision-capable model. + +### Resume state + +Change user transcript content to canonical content-block arrays. Change each tool transcript entry from an output string to `call_id` plus canonical `ok`, `content`, and `metadata` fields. Encode images in resume JSON as: + +```json +{ + "type": "image", + "media_type": "image/png", + "data": "" +} +``` + +Text blocks use `{"type": "text", "text": "..."}`. + +Bump the built-in transcript resume version from 3 to 4. Reject older versions with the existing regenerate-state guidance. Keep the approval envelope version unchanged because it already contains independently versioned provider state. An approval envelope captured before this change fails on resume with `approval state provider_state version 3 is not supported`; test and document this release break. + +Resume stays self-contained. Image bytes therefore appear as base64 and add about 33% encoding overhead. Document resume state and completed results as sensitive and potentially large. + +Cross-provider resume must preserve block order and image bytes. Same-provider resume must continue to preserve native reasoning data. + +### Tracing and events + +Never put image bytes, base64, or data URLs into trace attributes or non-terminal progress events, including when text message capture is enabled. + +Project each image as a descriptor: + +```json +{ + "type": "image", + "media_type": "image/png", + "size_bytes": 12345, + "block_index": 1 +} +``` + +Keep text blocks visible under the existing capture settings. Preserve content order and add each image block's zero-based index to event and trace projections. + +Keep current event field types and text-only values unchanged. For multimodal values, `RunStartedEvent.prompt` and `ToolCallCompletedEvent.output` contain compact redacted JSON strings. `ToolCallCompletedEvent.message` stays a plain text summary and never receives a block sequence. Apply the same projection before writing `gen_ai.prompt` and `gen_ai.tool.call.result` trace attributes. Cover `core.py`, `tool_execution.py`, subagent tracing, and transcript rendering as well as the projection and event modules. + +`RunCompletedEvent.result` remains the same complete result returned by `run()`. It can contain image-bearing resume state and tool-call records. Document this terminal object as sensitive instead of weakening result identity. + +Before creating a provider error event or span error, replace each run-known full base64 value and data URL with `[image data redacted]`. Also redact any complete `data:image/...;base64,...` value found by pattern. Provider adapters must not include serialized request bodies in error messages. + +### Filesystem plugin + +Add an opt-in `read_image` tool. Do not change the text `read` tool and do not add `read_image` to the filesystem plugin's default tool set. + +`read_image` will: + +- reuse the filesystem plugin's read path policy +- read one local file as bounded bytes +- detect PNG, JPEG, GIF, or WebP from file signatures +- return compact metadata as one `TextBlock`, followed by one `ImageBlock` +- reject directories, missing files, path escapes, unknown formats, SVG, and oversized files + +Add a frozen `max_image_bytes` filesystem-plugin setting with a default of `5_000_000`. Require a positive integer. This limit is independent of `max_read_bytes`; exactly the limit is accepted and one byte over is rejected. Do not add Pillow or another image dependency. Validate the full required signature and minimum header length, not only the first magic bytes. The tool validates container format only; providers validate dimensions and model-specific limits. + +`read_image` returns `ToolResult(ok=True, content=[TextBlock(metadata), ImageBlock(...)], metadata=...)`. The compact metadata text comes first and includes the path, media type, and byte size. + +### MCP plugin + +Preserve successful MCP image blocks as `ImageBlock` values in their original order. If `structuredContent` is present, it remains the authoritative text as required by current behavior: emit its canonical JSON as the first `TextBlock`, discard MCP text blocks, then append image blocks and image placeholders from `result.content` in their original relative order. + +A supported media type with valid base64 becomes an `ImageBlock`. Malformed base64 and unsupported image media types become the existing `[image: ]` text placeholder instead of failing the tool call. Continue converting audio, embedded resources, and resource links to text placeholders. + +Protocol-level MCP errors remain text-only retry results. + +## Behavior documentation + +After plan approval and before implementation, update only affected sections of `docs/behavior.md`: + +- add a new image-content section covering public blocks, validation, ordering, provider behavior, tool results, and exclusions +- update Resume State for transcript version 4 and self-contained image data +- update Model Observability Projections for redacted image descriptors +- update Filesystem Plugin for opt-in `read_image` and `max_image_bytes` +- update MCP Client Layer so successful image blocks remain images +- update hook and stream requirements where they currently assume string prompts or outputs + +## Implementation sequence + +### 1. Add and test neutral content types + +Create `thinharness/content.py` with public types, normalization, validation, canonical JSON encoding, canonical JSON decoding, and redacted descriptor helpers. + +Export public content types from `thinharness/__init__.py`. + +Prove that string prompts and text-only tool results retain their current observable behavior. + +### 2. Move the run and hook interfaces to content + +Update: + +- `thinharness/core.py` +- `thinharness/hooks.py` +- `thinharness/turns.py` +- `thinharness/runtime.py` +- `thinharness/events.py` + +Pass normalized content from public entry points to `ModelSession`. Append hook context and notices as text blocks without changing image order. Add redacted run-start event and trace projection in this phase, so the type change cannot expose image data. + +Update custom-model protocols and test fakes to use `continue_with_user_content`. + +### 3. Move tool execution to content + +Update: + +- `thinharness/tools/base.py` +- `thinharness/tool_execution.py` +- after-tool hook handling +- tool-call records + +Keep all existing text tools returning strings. Add multimodal normalization only at the shared tool seam. Define the exact envelope-header serializer and redact tool completion events and trace attributes in this phase. + +### 4. Implement provider serializers + +Update exact request construction in `thinharness/providers.py` for: + +- initial multimodal prompts +- resumed prompts +- mixed text/image tool results +- parallel tool-result batches +- notices added after multimodal content + +Keep text-only provider request payloads unchanged. Implement the decided OpenRouter labelled user-message projection in both live tool continuation and transcript replay. + +### 5. Upgrade transcript resume + +Update transcript entries, codecs, validation, provider transcript renderers, and approval-resume coverage for version 4. + +Test same-provider and cross-provider resume with mixed text and images. + +### 6. Redact observability surfaces + +Audit and complete redaction in: + +- `thinharness/core.py` +- `thinharness/tool_execution.py` +- `thinharness/projections.py` +- `thinharness/tracing.py` +- `thinharness/events.py` +- `thinharness/plugins/subagents.py` +- `scripts/build_transcripts.py` + +Ensure no trace or non-terminal event contains base64 or data URLs. Earlier phases add redaction at each new image-bearing seam; this phase is the full leak audit. + +### 7. Add image-producing built-ins + +Add opt-in filesystem `read_image`, then preserve MCP images. Keep Bash unchanged. + +### 8. Update documentation and release notes + +Update: + +- `README.md` +- `docs/docs.md` +- `CHANGELOG.md` +- API exports and examples + +Version changes follow the normal release process rather than this implementation plan. + +## Tests + +Tests must enter through public run, tool, resume, and plugin interfaces. Provider HTTP handlers are the only mocked boundary. Exact expected payloads must be literal fixtures derived from provider formats, not generated by production serializers. + +### Content contract + +- normalize string prompts to one text block +- preserve mixed block order +- reject empty and unsupported content, including the newly invalid empty string prompt +- detach caller-owned sequences and reject mutable byte containers +- redact `ImageBlock.__repr__` +- keep `thinharness/content.py` independent of core, providers, and tools +- round-trip canonical text and image JSON + +### Provider contracts + +For OpenAI, Anthropic, and OpenRouter: + +- send an initial image prompt +- send interleaved text and images +- continue after a text tool result without changing existing payload shape +- continue after an image tool result while keeping `ok` and metadata model-visible +- preserve several parallel tool results and their call IDs +- keep every existing text-only prompt and tool envelope request body byte-for-byte +- append notices after image content +- migrate both prompt hook fields and normalize valid hook replacements +- surface the normal provider error for a non-vision model + +### Resume + +- same-provider image replay for all built-in providers +- every cross-provider pair, including replay of a foreign tool image through OpenRouter's labelled projection +- mixed user and tool images +- preserved native reasoning on same-provider replay +- malformed base64, wrong keys, unsupported media types, and old versions fail clearly +- approval pause and resume with image-bearing prior state +- a pre-change approval envelope fails with the documented nested provider-state version error + +### Tool behavior + +- custom sync and async tools return image content +- image content survives mutation through both after-tool hook fields +- malformed hook JSON and unknown block values fail strict canonical validation +- failed and retryable image results keep `message` text-only and keep retry metadata visible to the model +- failed and retryable tools remain text-only unless explicitly returned otherwise +- parallel mixed text/image tool batches preserve order +- Bash never auto-loads a printed image path + +### Filesystem and MCP + +- `read_image` is opt-in and absent from defaults +- allowed PNG, JPEG, GIF, and WebP signatures +- truncated headers and files with only spoofed leading magic bytes fail +- exactly `max_image_bytes` succeeds and one byte over fails +- missing file, directory, unreadable file, path escape, and symlink escape +- plugin configuration remains frozen and binding performs no image I/O +- MCP preserves mixed text/image order; audio and resources remain placeholders +- MCP `structuredContent` plus images follows the declared precedence +- malformed MCP base64 and unsupported image media types become placeholders + +### Observability + +- traces preserve text and image descriptors in order +- traces and progress events contain no image bytes, base64, or data URLs +- provider errors cannot leak known base64 or data URLs through `RunFailedEvent` or span errors +- `RunStartedEvent`, `ToolCallCompletedEvent`, subagent traces, and transcript renderers expose descriptors only +- completed results retain full resume state + +### Live validation + +Add an opt-in image journey for each built-in provider. Each journey must prove that a real vision-capable model can: + +1. answer a question about a supplied image +2. inspect an image returned by a custom tool +3. resume from the resulting state + +The OpenRouter journey verifies the labelled user-message image projection. The OpenAI journey verifies the documented `function_call_output.output` content-array contract. + +## Validation commands + +After implementation: + +```bash +uv run ruff check thinharness tests +uv run pytest tests/unit +uv run pyright +``` + +Run relevant provider journeys when credentials are available. Do not require paid live calls in normal CI. + +## Main risks + +- **OpenRouter attribution:** The portable labelled user-message projection keeps call IDs visible but cannot give the image a native tool role. +- **Data leakage:** Existing trace, event, hook, and error paths assume text and may expose base64 unless every projection is updated. +- **State growth:** Self-contained resume and tool records can become large. +- **Interface spread:** Prompt and tool content currently use strings across most core seams. +- **False support claims:** A provider protocol can support images while a selected model does not. +- **Text regressions:** Multimodal normalization must not alter current text-only payloads or tool envelopes. + +## Review decision + +Review panel v1 requested changes. This revision resolves the blocking provider projections, tool envelope, hook mutation, MCP precedence, event redaction, and filesystem limit decisions. + +The plan is ready for human approval. After approval, update `docs/behavior.md` with this contract before implementation starts. diff --git a/.plans/43-plugin-architecture-deepening.md b/.plans/43-plugin-architecture-deepening.md new file mode 100644 index 0000000..b2fed8f --- /dev/null +++ b/.plans/43-plugin-architecture-deepening.md @@ -0,0 +1,126 @@ +# Plugin architecture deepening — plan v2 + +Tighten the 0.7 plugin architecture in three places: share the repeated built-in plugin rules, make the child host own its agent catalog, and replace the private dictionary-shaped tool runtime context with a typed runtime scope. + +This is one architecture change. The three parts simplify related plugin and child-run interfaces without changing model-visible tool behavior. + +## Decisions + +1. **Keep the public plugin contract structural.** Custom plugins continue to satisfy the `Plugin` protocol without inheriting from a ThinHarness base class. +2. **Share only built-in policy.** Add private support for fixed built-in names and frozen built-in configuration. Do not expose this support as a public extension interface. +3. **Keep MCP configuration mutable.** `MCPPlugin` shares the fixed-name rule but does not gain the freeze rule. `FilesystemPlugin`, `SkillsPlugin`, `ParallelLlmPlugin`, `SubagentsPlugin`, and `BashPlugin` keep their current frozen configuration behavior. +4. **The parent child host owns the agent catalog.** Delegation recipes already contain their agent names. `PluginBinding` no longer repeats those names in a separate field. The catalog and sealing controls stay private to the core host; the public `ChildHarnessHost` protocol remains unchanged. +5. **Delegation registration is static and atomic.** A plugin registers delegation tools and recipes during synchronous `bind()`. Core seals registration after all bindings and before reading the catalog. A failed or late registration changes no provenance, recipes, or names. +6. **Agent names identify hook-filter groups, not one tool.** The host records an ordered unique catalog. Repeated names across registrations are valid, which allows two delegation tools to use the same agent identity. Blank names fail with `ValueError`. +7. **Runtime requests must use the sealed catalog.** `run()` rejects an unregistered agent name before hooks or child creation. Other request fields may differ from the registered recipe because the delegation plugin owns call-time request construction. +8. **Bind-time registration covers connected tools.** A tool registered during `bind()` enters the static agent catalog even when the tool is contributed later by `connect()`. Registration from `connect()` or a live tool handler fails because the host is sealed. +9. **The runtime scope stays private and narrow.** It contains only the active lease, copied run metadata, and the active run's tool and composition snapshot maps. It does not become a general capability or policy object. +10. **Preserve runtime identity.** The typed scope keeps references to the same tool and composition maps, and copied async contexts share the same mutable lease. Lease revocation must still block detached tasks after a tool call ends. +11. **Do not add compatibility paths.** This is a pre-1.0 interface cleanup. Do not preserve `PluginBinding.agent_names` as an alias or deprecated field. + +## 1. Share built-in plugin policy + +Add one private module under `thinharness/plugins/` for built-in plugin identity and freezing rules. + +- Use a shared private metaclass that receives and installs each built-in's fixed name when that built-in class is created. It must allow the first built-in declaration while rejecting later class assignment, class deletion, and subclass replacement of the name. +- Use the fixed-name implementation for `MCPPlugin`, `FilesystemPlugin`, `SkillsPlugin`, `ParallelLlmPlugin`, `SubagentsPlugin`, and `BashPlugin`. +- Add one private frozen-plugin base for the five plugins that are already frozen. Its freeze checks must use direct object state rather than `getattr()`, so `ParallelLlmPlugin.__getattr__` cannot intercept the check. +- Preserve each plugin's constructor validation, copied configuration, properties, `for_child()` behavior, binding behavior, fixed-name error detail, and model-visible tools. +- Make instance assignment and deletion of `.name` raise the fixed-name error for all six built-ins. This standardizes MCP instance deletion with its existing assignment rule. +- Preserve `ParallelLlmPlugin.__getattr__` behavior. +- Keep `MCPPlugin` mutable for every attribute except `name`. +- Keep the public `Plugin` protocol unchanged. + +Use one parameterized fixed-name contract test for all six built-ins: instance assignment, instance deletion, class assignment, class deletion, and subclass name replacement fail. Use a second parameterized test for post-construction assignment and deletion on the five frozen plugins. Prove separately that non-name MCP assignment and deletion remain possible and that parallel-LLM public values still return detached data. + +## 2. Move agent names into the parent child host + +Make `_ParentChildHarnessHost` the one source of truth for statically registered child recipes and their agent names without widening `ChildHarnessHost`. + +- Keep the ordered catalog accessor and sealing operation private to `_ParentChildHarnessHost`. +- `register_delegation_tool()` first validates the tool, ordered recipe sequence, recipe values, and every non-empty agent name. It commits delegation provenance, recipes, and new catalog names only after all validation succeeds. +- Preserve registration order and append each agent name only once. Repeated names within or across registrations remain one catalog entry. +- Seal the parent host immediately after every configured plugin has returned a valid binding. Seal before contribution normalization, hook-filter validation, connection, or any run. +- A registration attempt after sealing raises `HarnessError` and changes no host state. +- `_DisabledChildHarnessHost` stays stateless and keeps rejecting registration and execution with `HarnessError`. +- Remove `agent_names` from `PluginBinding`. +- Remove core's binding-level agent-name collection and validation. Core validates `Hook.agents` against the sealed parent-host catalog after static plugin composition and after connected hook composition. +- `SubagentsPlugin.bind()` registers its existing default and named recipes but no longer returns a duplicate agent-name tuple. +- `_ParentChildHarnessHost.run()` keeps the active lease and registered-delegation-tool guards and also rejects any request whose `agent_name` is absent from the sealed catalog. +- Preserve current default-name handling, named-agent order, hook dispatch, child construction, and connected-contribution atomicity. + +Before implementation, update only the affected current-state requirements in `docs/behavior.md`: `PLUGIN-3`, `PLUGIN-11`, and `SUBAGENTS-PLUGIN-4`. The contract must say that synchronous child-host registration supplies the static catalog, bind-time registration covers a tool contributed through `connect()`, registration is sealed before connection, and connected contributions cannot change the catalog. Do not add migration wording. + +Tests must cover: + +- catalog order within one plugin and across two delegation plugins; +- repeated names across alias tools without duplicate catalog entries; +- blank-name and invalid-recipe failures that leave provenance, recipes, and the catalog unchanged; +- valid default, named, custom, and connect-contributed agent filters; +- unknown hook filters; +- registration rejection from a connector and from a live tool handler, with no host mutation; +- runtime rejection of an unregistered request name before hooks or child creation; +- disabled-host registration and execution rejection; +- a non-delegation tool calling `host.run()` and receiving the registered-delegation-tool error; +- reuse of one `SubagentsPlugin` across two harnesses, proving sealing is per host. + +## 3. Add a typed tool runtime scope + +In `thinharness/hooks.py`, replace `_CURRENT_TOOL_RUNTIME`'s `dict[str, Any]` value with one private frozen dataclass. Import `_ToolComposition` only under `TYPE_CHECKING` to avoid the existing `children.py` to `hooks.py` runtime dependency becoming a cycle. + +```python +@dataclass(frozen=True) +class _ToolRuntimeScope: + lease: _ToolRuntimeLease + run_metadata: Json + tool_map: dict[str, ToolSpec] + tool_composition: dict[str, _ToolComposition] +``` + +- `ToolCallExecutor` creates the scope for each active tool call. +- Child execution reads named fields instead of string keys. +- Remove only defensive dictionary-shape parsing made unnecessary by the private typed producer. Keep lease activity validation and authoritative delegation-composition validation. +- Metadata remains copied when the scope is created. +- Tool and composition fields keep the active run's existing snapshot maps by reference; the frozen dataclass does not claim to make those dictionaries deeply immutable. +- The lease remains one shared mutable object and is revoked on every tool-call exit path. +- Keep `current_tool_runtime_context()` internal and do not export the scope type from the package. + +Tests must prove: + +- the runtime context is absent outside a tool call; +- metadata is copied while tool-map and composition-map identity is preserved inside a tool call; +- copied async contexts receive the same lease; +- lease revocation blocks later child execution after normal completion, handler exception, and cancellation; +- the registered-delegation guard remains effective. + +## Files expected to change + +- `docs/behavior.md` +- `thinharness/plugins/_builtin.py` or one equivalent private module +- `thinharness/plugins/{bash,filesystem,mcp,parallel_llm,skills,subagents}.py` +- `thinharness/plugins/base.py` +- `thinharness/children.py` +- `thinharness/core.py` +- `thinharness/hooks.py` +- `thinharness/tool_execution.py` +- focused tests, especially `tests/unit/test_subagents.py`, `test_plugins.py`, `test_mcp.py`, `test_skills.py`, `test_parallel_llm.py`, `test_bash_plugin.py`, `test_hooks.py`, `test_tool_retry.py`, and `test_architecture.py` + +Do not change README copy, package exports, model-visible schemas, provider behavior, MCP provenance, tool-composition snapshots, release automation, or version numbers in this change. Do not add a release note for an intermediate plugin interface that has not been released. + +## Acceptance checks + +- The six built-in plugin names and the existing five frozen plugin configurations keep their intended behavior with one shared implementation. +- `MCPPlugin` remains mutable except for its fixed name. +- `PluginBinding` contains only `static` and `connect`. +- The public `ChildHarnessHost` protocol remains unchanged. +- Agent-filtered hooks use the sealed private host catalog, including custom and connect-contributed delegation tools registered during `bind()`. +- Registration after synchronous binding cannot change agent names, recipes, or delegation provenance. +- Runtime child requests cannot use names outside the sealed catalog or run from an ordinary tool. +- Child delegation still uses the active run's existing tool and composition snapshot maps. +- Copied async contexts share lease revocation and cannot delegate after the parent tool call ends. +- No public compatibility layer or migration documentation is added. +- Focused plugin, subagent, hook, retry, and architecture tests pass. +- `uv run ruff check .` passes. +- `uv run pyright` passes. +- `uv run pytest` passes. +- `git diff --check` passes. diff --git a/.plans/44-child-delegation-contract.md b/.plans/44-child-delegation-contract.md new file mode 100644 index 0000000..a57b3ea --- /dev/null +++ b/.plans/44-child-delegation-contract.md @@ -0,0 +1,32 @@ +# Child delegation contract — plan + +Fix three small pre-0.7 defects in the public child-delegation interface. + +## Scope + +1. **Use the real child model for plugin validation.** Parent construction must not bind an override-model child's plugins against the parent model. For a child without a model override, keep eager plugin-tool validation against the borrowed parent model. For an override-model child, validate plugin names, direct tools, and other model-independent recipe rules during parent construction, then validate plugin contributions when the real child model is constructed. Do not infer or open the override model during parent construction. +2. **Keep one inheritance state.** Remove `ChildHarnessRequest.inherited`. `tool_mode` is authoritative: `"explicit"` does not inherit; `"inherited"` and `"inherited+explicit"` inherit. Continue exposing the derived boolean in hook contexts and delegation result metadata. +3. **Keep one parent harness reference.** Remove `BeforeSubagentRunContext.parent_harness`. Both before- and after-subagent hooks use the inherited `HookContext.harness`, which is the parent harness. + +This is a clean pre-1.0 interface correction. Do not add aliases, deprecated properties, fallback reads, migration documentation, or compatibility constructors. + +## Tests + +- Reproduce the override-model false duplicate rejection and prove parent construction now accepts the valid recipe. +- Prove an override-model plugin collision or approval violation is rejected when the real child is constructed. +- Prove parent-model child plugin collisions still fail during parent construction. +- Prove each `tool_mode` produces the correct inherited composition, hook boolean, and result metadata. +- Prove before- and after-subagent hooks receive the parent through `ctx.harness` and that the before context has no `parent_harness` field. +- Update all direct `ChildHarnessRequest` constructions and type assertions. + +## Non-goals + +Do not change provider modules, model lifecycle, plugin registration, the sealed agent catalog, tool composition provenance, README copy, versions, or release automation. + +## Validation + +- Focused subagent, hook, tracing, and architecture tests. +- `uv run ruff check .` +- `uv run pyright` +- `uv run pytest` +- `git diff --check` diff --git a/.plans/45-provider-package.md b/.plans/45-provider-package.md new file mode 100644 index 0000000..712bf06 --- /dev/null +++ b/.plans/45-provider-package.md @@ -0,0 +1,46 @@ +# Provider package reorganization — plan + +Replace the 1,820-line `thinharness/providers.py` module with a focused `thinharness/providers/` package. Preserve behavior and the existing public import path. + +This is a pure refactor. Do not change provider payloads, request settings, retries, resume state, model/session protocols, inference behavior, exports, or error messages. + +## Package structure + +- `providers/base.py`: provider-neutral model turns, tool outputs, notices, request constants, capabilities, settings, model/session protocols, and neutral normalization helpers. +- `providers/transport.py`: shared HTTP transport policy, retry parsing and delays, `ProviderError`, and the base provider lifecycle. +- `providers/transcript.py`: provider-neutral transcript entries, resume-state encoding and validation, transcript mutation, image recovery, and shared reasoning fallback behavior. +- `providers/openai.py`: `OpenAIProvider`, `OpenAIResponsesModel`, `OpenAIResponsesSession`, OpenAI payload rendering, transcript replay, structured-output conversion, and response extraction. +- `providers/anthropic.py`: `AnthropicProvider`, `AnthropicMessagesModel`, `AnthropicMessagesSession`, Anthropic payload rendering, transcript replay, structured-output conversion, and response extraction. +- `providers/openrouter.py`: `OpenRouterProvider`, `OpenRouterModel`, `OpenRouterSession`, OpenRouter payload rendering, transcript replay, structured-output conversion, and response extraction. +- `providers/__init__.py`: explicit public re-exports plus model-reference parsing, provider-prefix resolution, capability lookup, same-provider checks, and `infer_model` dispatch. + +Each provider file must own its complete wire dialect. Shared modules must not import concrete provider adapters. Avoid wildcard imports and pass-through wrapper functions. + +## Interface rules + +- `from thinharness.providers import ...` continues to expose every current public name used by ThinHarness, tests, examples, and applications. +- Top-level `from thinharness import ...` exports remain unchanged. +- Internal callers that currently import private shared helpers from `thinharness.providers` continue to resolve through explicit package exports or move to the correct focused module. +- Do not add aliases for removed private locations. The package path itself is the current public interface. +- A custom application model or transport remains definable outside ThinHarness and injectable through `Harness(model=...)` or an existing built-in model's `provider=` argument. +- Delete `thinharness/providers.py` after the package is complete. Do not keep both forms. + +## Tests + +Split `tests/unit/test_providers.py` into focused files for neutral/factory behavior, transport/retries, transcript behavior, OpenAI, Anthropic, and OpenRouter. Keep cross-provider image, reasoning, resume, tracing, and harness tests in their existing files. + +Preserve every existing assertion. Add or adjust architecture tests so they prove: + +- `providers.py` no longer exists and the focused package modules do; +- each provider adapter lives in its own module; +- current public imports resolve from `thinharness.providers` and `thinharness`; +- a small external-style custom model and a custom built-in transport still inject without source edits. + +## Validation + +- Focused provider, resume, image, reasoning, tracing, parallel-LLM, subagent, and architecture tests. +- `uv run ruff check .` +- `uv run pyright` +- `uv run pytest` +- `git diff --check` +- Build a wheel in a temporary directory, install it into a clean temporary virtual environment, and import all current top-level exports plus all public `thinharness.providers` exports. diff --git a/.plans/image-inputs.md b/.plans/image-inputs.md deleted file mode 100644 index 81dd4dd..0000000 --- a/.plans/image-inputs.md +++ /dev/null @@ -1,26 +0,0 @@ -# Image Inputs - -ThinHarness should support images by making model input richer while keeping plain text prompts unchanged. The public API should continue to accept `Harness.run("prompt")`, and additionally accept an ordered sequence of input parts such as text plus image content. - -The core type should be provider-neutral: - -```python -UserInput = str | Sequence[UserInputPart] -UserInputPart = TextPart | ImageUrlPart | BinaryImagePart -``` - -`TextPart` can stay optional at first because bare strings inside the sequence are enough for most callers. `ImageUrlPart` should hold a URL, optional MIME type, optional provider metadata, and an optional `force_download` flag. `BinaryImagePart` should hold bytes, MIME type, and optional provider metadata, with helpers such as `from_path(...)` and `from_data_uri(...)`. - -The harness should normalize prompt handling once near the run boundary. Hooks, tracing, stream events, and model session APIs should receive the neutral input shape instead of assuming a string. Text-only callers should see the same behavior and payloads they see today. - -Provider adapters should own wire-format mapping: - -- OpenAI Responses: map text to `input_text` and images to `input_image`; binary images should use data URIs, URL images should use URLs unless `force_download` is set. -- Anthropic Messages: map text to text blocks and images to image blocks; binary images should use base64 source blocks, URL images should use URL source blocks when supported. -- OpenRouter chat completions: map text and images to chat content parts using `text` and `image_url`; binary images should use data URIs. - -Unsupported image cases should fail loudly with a provider error. The harness should not silently stringify images or drop them. - -Keep the first implementation limited to images. Do not include audio, video, PDFs, uploaded provider files, or prompt-cache markers yet. The type shape should leave room for those later, but the code should not implement speculative modalities. - -Tests should cover backward-compatible text prompts, mixed text/image ordering, URL image mapping, binary image mapping, provider-specific unsupported cases, notice appending with rich input, and tracing redaction or placeholder behavior so raw image bytes are not accidentally written into local traces. diff --git a/.plans/plugin-foundation-review-packet.md b/.plans/plugin-foundation-review-packet.md new file mode 100644 index 0000000..9902fb1 --- /dev/null +++ b/.plans/plugin-foundation-review-packet.md @@ -0,0 +1,10 @@ +# Plugin foundation review packet + +Review these two implementation plans as one ordered migration: + +1. [Plugin system and filesystem plugin](37-plugin-system-and-filesystem.md) +2. [MCP plugin](38-mcp-plugin.md) + +Read both linked files in full. Review each plan and the seam between them. In particular, check whether plan 37 leaves a complete and testable plugin interface, whether plan 38 can use that interface without MCP-specific changes to core, and whether the temporary subagent bridges create avoidable dual systems. + +Return one verdict with findings labeled `Plan 37`, `Plan 38`, or `Cross-plan`. Raise decisions that require product-owner input separately from implementation corrections. diff --git a/CHANGELOG.md b/CHANGELOG.md index c7b30b7..7909c3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,34 @@ # Changelog +## 0.7.0 - 2026-08-20 + +- Added ordered text and image prompts and tool results for OpenAI, Anthropic, and OpenRouter, with provider-neutral immutable content blocks, redacted observability projections, self-contained version 4 resume state, opt-in filesystem `read_image`, and preserved successful MCP images. +- **Breaking:** Renamed custom `ModelSession.continue_with_user_text(...)` to `continue_with_user_content(...)`; prompt hooks now receive normalized content-block tuples, and built-in transcript resume version 3 state must be regenerated. +- **Breaking:** `ToolOutput` now requires `ToolOutput(call_id, ToolResult(...))`; direct string output construction is no longer accepted. +- Added explicit plugin composition with static and connected contributions, atomic connection rollback, unique plugin names, generic tool origin, and plugin-provided hooks and instructions. +- Added `FilesystemPlugin` for the ordered workspace tool surface; `jsonl_search` remains opt-in through this plugin. +- Added `BashPlugin` for explicit one-shot local Bash commands with strict arguments, contained cwd, minimal environment inheritance, bounded separate head-and-tail output, host-capped timeouts, process-group cleanup, and cancellation propagation. +- Added `MCPPlugin` for lazy MCP server connection, binding-local server identity, atomic tool discovery, and generic tool origin attribution. +- Added `SkillsPlugin` for constructor-time skill discovery, explicit ordered skill-tool selection, static summaries, and shared inherited-child catalogs. +- Added `ParallelLlmPlugin` for explicit text-only batch composition with borrowed harness or caller models and plugin-owned string-model provider settings. +- Added explicit `SubagentsPlugin` composition for the default child, named child recipes, child-local hooks, additive inheritance, and delegation result shaping. +- Added a narrow `ChildHarnessHost` to `PluginContext`, public child request/outcome contracts, and structural `ChildInheritablePlugin.for_child()` rebinding. +- Added run-frozen authoritative tool composition provenance for direct-tool inheritance and `subagent.delegation` tracing. +- **Breaking:** Removed the public `BashTool` and `BashArgs` interfaces. Configure `plugins=[BashPlugin()]`; Bash no longer accepts model-selected output limits or direct tool registration. +- **Breaking:** Removed `HarnessConfig.mcp_servers`, `McpToolInfo`, and the MCP `ToolKind`; configure one `MCPPlugin` with all harness servers. +- **Breaking:** Generic plugin validation now reports MCP tool collisions as duplicate tool names. Use MCP `tool_prefix`, `include_tools`, or `exclude_tools` to prevent collisions. +- **Breaking:** Removed `MCPServer.resolve_id()` and post-bind mutation of `server.id`. The public `server.id` remains the base ID; binding-local IDs, including duplicate suffixes, appear in tool origin and result metadata. +- **Breaking:** `Harness` no longer enables filesystem tools by default. Pass `plugins=[FilesystemPlugin(...)]`; independent custom tools still use `tools=`. +- **Breaking:** Removed filesystem settings from `HarnessConfig` and removed the `builtin_tools()` helper. +- **Breaking:** Removed `HarnessConfig.skills_dir`, `selected_skills`, `read_paths`, `write_paths`, `builtin_parallel_llm_model`, `builtin_parallel_llm_temperature`, and `parallel_llm_max_prompts`; use `SkillsPlugin` and `ParallelLlmPlugin`. +- **Breaking:** Removed the `Harness(skills=...)` composition path and `create_parallel_llm_tool`; named children now use explicit plugins or tools. +- **Breaking:** Removed `HarnessConfig.builtin_tools`, `HarnessConfig.subagents`, and `Harness(subagent_hooks=...)`. ThinHarness has no implicit tool selector; use `FilesystemPlugin`, `SkillsPlugin`, `ParallelLlmPlugin`, and `SubagentsPlugin` explicitly. +- **Breaking:** Removed `SubAgentConfig.builtin_tools`, `inherit_parent_tools`, `inherit_mcp_servers`, and `mcp_servers`. Use additive `inherit_parent=True`, explicit child plugins, and an explicit child `MCPPlugin`. +- **Breaking:** Removed `create_subagent_tool`, `build_child_harness`, the `thinharness.subagents` module, `ToolKind`, and `ToolSpec.kind`. A custom direct tool can now use the name `subagent` when `SubagentsPlugin` is absent. +- **Breaking:** Parent plugins inherit only when they implement `for_child()`. Filesystem, skills, and parallel LLM plugins opt in with frozen constructor configuration; MCP and subagents do not. An inherited `ParallelLlmPlugin(model=None)` now borrows the child model. +- Changed connection setup to complete before `run_start` hooks. A connection failure does not fire run lifecycle hooks. +- Fixed Anthropic metadata projection to send only the Messages API's supported `user_id`, while keeping child correlation metadata inside the harness. + ## 0.6.0 - 2026-08-07 - Added automatic retries with bounded exponential backoff, jitter, and `Retry-After` support for transient OpenAI, Anthropic, and OpenRouter request failures. Configure retries with `HarnessConfig.request_retries` and `request_retry_backoff`. diff --git a/README.md b/README.md index 2017452..b3c43c8 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,9 @@


- A minimal, opinionated agent harness — + A compact, SDK-only agent harness:
- focused scope, straightforward code, easy to fork. + maximum performance, minimum framework code

@@ -18,216 +18,48 @@ -## Why this exists +## Why keep it small -*ThinHarness is for building agents with a defined job and a bounded set of tools, using a refreshingly simple framework that's easy to inspect and customize.* +Minimal agent harnesses are becoming more common. [Pi](https://github.com/earendil-works/pi) has shown how far a focused, token-efficient harness can go, and [Deep Agents](https://github.com/langchain-ai/deepagents) is close behind for cost/performance on [Composio's benchmark](https://composio.dev/content/best-agent-harness-deepseek-v4-flash). Vercel recently released [fx](https://github.com/vercel-labs/fx), which is deliberately tiny and embeddable. ThinHarness takes the same direction to its limit: how little framework code can you keep without giving up capability or performance? -Production agents rarely stop at framework configuration. Things like orchestration, permissions, user/session storage, and deployment become specific to the application and its users. +Larger harnesses are larger for good reasons. They support more providers, storage systems, sandboxes, durable jobs, deployment targets, integrations, and interactive interfaces. ThinHarness makes fewer promises. The core owns the model and tool loop and the behavior that must stay consistent across every run. Optional capabilities live in explicit plugins. -ThinHarness exists for the gap between building the agent loop yourself and adopting a large agent runtime where the loop comes bundled with assumptions you don’t need and can’t easily change. +For a side project, that means less framework code to learn, configure, debug, update, and carry in a fork. It also means the project can become complete. ThinHarness does not need to keep growing into an agent platform after it has the features its agents need. -It owns a focused set of agent-loop primitives that generalize well and are tedious to rebuild, leaving the rest of the application stack for you to own. +## Benchmarking -I started building ThinHarness after running into this gap in practice. Filesystem-enabled agents are simple yet powerful, but you mostly get them by adopting a large framework with layers of abstraction. I usually needed only a small slice of the functionality, but that slice came with coupled assumptions that didn't match my application. Making it fit meant writing enough wrappers, adapters, and fixes that I ended up owning framework-shaped code anyway. +Benchmarking is in progress. - +ThinHarness powers [Retrodict](https://github.com/ryanbbrown/Retrodict), a specialized ARC-AGI-3 agent that leads the reported cost-performance frontier for public ARC-AGI-3 harnesses. Its official competition-mode scorecard reports 99.86% mean RHAE across all 25 public games, with all 183 levels solved. See the [ARC-AGI Community Leaderboard submission](https://github.com/arcprize/ARC-AGI-Community-Leaderboard/tree/main/submissions/retrodict). This is application evidence, not an isolated comparison of generic harness loops. -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
LibraryLOC1Tool
retries2
SubagentsSkillsFS
tools
OTel
tracing
ThinHarness8,035
- -  Claude Agent SDK - 8,2633⚠️
- -  smolagents - 9,840⚠️
- -  deepagents - 17,6644
- -  AWS Strands - 32,526⚠️
- -  Microsoft
- Agent Framework -
41,331
- -  Pydantic AI - 59,087
- -  Google ADK - 65,799⚠️
- -  OpenAI Agents SDK - 73,796⚠️⚠️
- -  Agno - 113,477⚠️⚠️
-

* Table focuses on harness-level features that differentiate the libraries. All listed also support MCP, lifecycle hooks, multi-turn conversations, structured output, and human-in-the-loop. It intentionally does not compare framework/platform features like vector DB integrations, hosted deployment, memory/session stores, or broad SaaS connectors.

- -

- 1. LOC excludes anything that is not the core agent harness framework. See raw README source comments for exact commands.
- 2. Tool retries: a documented primitive (e.g. Pydantic AI's ModelRetry) that lets tools signal "model passed bad args — retry with this feedback," distinct from generic exception propagation.
- 3. Claude Agent SDK shells out to the Claude Code CLI binary, which is 200k+ LOC.
- 4. deepagents is a thin wrapper over LangChain/LangGraph; effective import surface is ≈112k LOC.
-

- -
- -
- -See [docs/table.md](docs/table.md) for per-cell rationale and how the LOC numbers are measured. - -## Opinions - -ThinHarness has opinions. They are the reason it stays small. - -**Purpose-built agents, not universal agents.** ThinHarness is for bounded agent loops, not open-ended interactive assistants like Claude Code or OpenClaw. For business use cases, focused agent loops orchestrated by deterministic code are usually a better fit than sprawling multi-agent systems with broad authority. - -**No bash by default.** Purpose-built business agents usually don't need a shell. Bash is a broad security and reliability surface: it gives the model open-ended authority instead of typed, bounded actions. ThinHarness keeps bash out of the default and built-in tool sets, but exposes an opt-in `BashTool` for exploratory runs before the workflow is hardened with typed tools. - -**Search is a top priority.** The `search` tool exposes ripgrep as compact grouped path/line results, tuned for document and business-workflow agents rather than code navigation. There's also a `jsonl_search` variant, because JSONL is the right shape when you're replacing RAG with agent-driven search over structured data: ripgrep row prefiltering, jq-style field projection, `where` filters, range filters, and snippets from large multiline fields. +## Features -**Parallel LLM calls, built in.** Fan out from inside the harness when a workflow needs efficient parallel processing or majority vote for reliability. Set `builtin_parallel_llm_model` to enable the default `parallel_llm` tool for plain-text batches; for validated structured output per call, instantiate `ParallelLlmTool` yourself with `output_type` (a Pydantic model). Each call is stateless, and large batches can write JSON to `output_file`. +The core contains the run behavior every configuration shares. Plugins add complete capabilities without making them implicit dependencies. -**No token streaming.** Streaming is for workflow progress, not live chatbot text. ThinHarness emits run, model-turn, tool, retry, limit, and subagent events, but it does not stream provider token deltas. Token streaming would add provider-specific plumbing, event merging, cancellation edge cases, and more surface area to keep stable. For workflow-style agents, step-level updates are usually the useful signal. +### Core -**Three providers, no matrix.** ThinHarness ships small provider classes for OpenAI, Anthropic, and OpenRouter. If your gateway speaks one of those protocols, you swap a base URL and move on. If not, the provider classes are small enough to fork or replace, and ignoring the bundled ones costs you nothing +- **Plugin composition:** explicit Python plugins can contribute tools, instructions, hooks, and connected resources. +- **Provider adapters:** built-in OpenAI, Anthropic, and OpenRouter adapters, plus public model and session protocols for implementing another provider. +- **Custom typed tools:** define sync or async `ToolSpec` handlers with Pydantic argument models, normalized `ToolResult` envelopes, parallel and approval flags, and per-tool retry settings. +- **Structured output:** Pydantic-validated results with native, tool, prompted, and text modes. +- **Text and images:** ordered text and image input in prompts and tool results, with JPEG, PNG, GIF, and WebP support across the built-in providers. +- **Resume:** self-contained transcript state can replay text and images across built-in providers and models, preserve native reasoning on same-provider resume, and degrade it to text across providers. +- **Parallel tool calls:** same-turn tool batches run concurrently when every called tool is parallel-safe. +- **Human approvals:** approval-required tools pause before side effects and return the pending call plus the state needed to continue after an approve or reject decision. +- **Tool retries:** tools raise `ModelRetry` to send structured feedback to the model and retry within a per-tool budget. +- **Limits and notices:** request, tool-call, output-retry, and tool-retry budgets bound each run; near-limit guidance can warn the model before a budget is exhausted. +- **Hooks and events:** lifecycle hooks can inspect or intercept prompts, tool calls, subagents, limits, and run boundaries; async streaming emits coarse run, model, tool, retry, limit, and subagent events. +- **Tracing:** local plaintext JSONL traces plus OpenTelemetry-compatible spans for runs, provider calls, tools, and subagents. -**No compaction.** Compaction is a workaround for context windows filling up across long, accumulating runs — useful for interactive coding sessions that sprawl over hours. For SDK-based business agents, the right answer to "context is getting big" is almost always better task decomposition: shorter runs, separate harness instances, narrower subagents. +### Plugins -**No deployment layer.** Agents still need serving, auth, durable jobs, user/session storage, and deployment in production. ThinHarness does not try to own that stack. A bundled deployment layer might work for some teams, but it will miss plenty of real production shapes; instead of adding more code and more options, ThinHarness leaves that application stack for you to own. +- **Filesystem:** root-scoped `read`, `write`, batched exact-replacement `edit`, `search`, `list`, and `glob`, plus opt-in bounded `read_image`. +- **JSONL search:** an opt-in `FilesystemPlugin` tool for structured search over line-delimited data, with ripgrep prefiltering, field projection, equality, contains, regex, and range filters, plus field-level snippets from large multiline values. +- **Bash:** one-shot non-interactive commands with a contained working directory, filtered environment, bounded output, timeouts, cancellation cleanup, and optional approval. It is not a sandbox. +- **MCP:** `MCPPlugin` support built on the FastMCP client, including in-process servers, lazy tool discovery, and collision checks. +- **Subagents:** a default child, named child configurations, explicit safe-plugin inheritance, local child hooks, and no recursive delegation. +- **Parallel LLM:** batches of independent one-shot prompts, with an optional separate model, structured results, and explicit read and write paths. +- **Skills:** ordered `skill_read` and `skill_run` tools, with Python, shell, JavaScript, and Go script runners. ## Install @@ -237,14 +69,17 @@ uv add thinharness # or pip install thinharness Requires Python 3.11+. -## Use +## Quick start ```python import asyncio -from thinharness import Harness, HarnessConfig +from thinharness import FilesystemPlugin, Harness, HarnessConfig async def main(): - async with Harness(HarnessConfig(root=".", model="openai:gpt-5.5")) as harness: + async with Harness( + HarnessConfig(root=".", model="openai:gpt-5.5"), + plugins=[FilesystemPlugin(tools=["read"])], + ) as harness: result = await harness.run("Read README.md and summarize it.") print(result.text) @@ -253,74 +88,21 @@ asyncio.run(main()) There's a synchronous wrapper too: `Harness(...).run_sync(...)`. -Built-in provider requests retry transient HTTP failures three times by default. Configure the shared policy with `request_retries` and `request_retry_backoff` on `HarnessConfig`. - -If an injected `http_client` owns retries, set `request_retries=0` on the provider. This prevents nested retry policies from multiplying attempts. - -For workflow visibility, use `Harness.stream(...)`: - -```python -from thinharness import RunCompletedEvent - -async for event in harness.stream("Process these records."): - if event.kind == "tool_call_started": - print(event.tool_name) - if isinstance(event, RunCompletedEvent): - result = event.result -``` - -Streaming emits coarse run, model, tool, retry, limit, and subagent events, then finishes with the same `HarnessResult` returned by `run()`. - -## Features - -- **Filesystem tools:** `read`, `write`, batched exact-replacement `edit`, `search`, `list`, and `glob` with root-scoped path policies. -- **JSONL search:** opt-in `jsonl_search` for structured line-delimited data, with ripgrep prefiltering, field projection, equality/contains/regex/range `where` filters, and field-level snippets from large multiline string values. -- **Bash prototype tool:** opt-in `BashTool` for exploratory shell commands. It is lightweight, custom-registration only, and is not included in the default or built-in tool set. -- **Provider adapters:** built-in OpenAI, Anthropic, and OpenRouter adapters, plus public model/session protocols for implementing another provider. -- **Custom typed tools:** define sync or async `ToolSpec` handlers with Pydantic argument models, normalized `ToolResult` envelopes, sequential/approval flags, and per-tool retry settings. -- **Structured output:** Pydantic-validated results with native, tool, prompted, and text modes. -- **Hooks:** lifecycle and tool-call interception for prompt submission, tool calls, subagents, limits, and run boundaries. -- **Subagents:** opt-in delegation through a built-in `subagent` tool and explicit `SubAgentConfig`. -- **Parallel LLM:** opt-in `parallel_llm` fan-out for batches of independent one-shot prompts, plus `ParallelLlmTool(...).spec()` for renameable tools with explicit model, path, prompt, and provider request settings. -- **Skills:** explicit `skill_read` and `skill_run` tools for selected skill directories, with Python, shell, JavaScript, and Go script runners. -- **Resume:** clean new-turn continuation through self-contained transcript state that can replay across built-in providers and models, preserving native reasoning on same-provider resume and degrading it to text across providers. -- **MCP:** optional MCP support built on the FastMCP client, including in-process servers via `FastMCPTransport`, with lazy tool discovery and collision checks. -- **Parallel tool calls:** same-turn tool batches run concurrently when every called tool is parallel-safe. -- **Human approvals:** mark custom tools as approval-required so a run pauses before side effects, returns pending call details plus resume state, then continues after an approve/reject decision. -- **Event streaming:** async coarse-grained run, model, tool, retry, limit, and subagent events for workflow visibility. -- **Tool retries:** tools raise `ModelRetry` to send structured feedback back to the model and trigger a retry within a per-tool budget. -- **Limits and notices:** configured request, tool-call, output-retry, and tool-retry budgets bound each run; near-limit guidance can warn the model before request or tool-call budgets are exhausted. -- **Tracing:** local plaintext JSONL traces plus OpenTelemetry-compatible spans for runs, provider calls, tools, and subagents. - -## Examples - -Three agents built on ThinHarness, from a self-contained demo to a benchmark run to one I use live. - -### 1. Web Research Report - -A market-landscape research agent that plans, runs batched Exa search, triages and fetches sources, extracts structured source notes with `parallel_llm`, drafts a report, and runs a `citation_critic` subagent before finalizing. - -It isn't meant to be a state-of-the-art research agent; it's a worked example showing the harness drive a non-trivial agentic loop correctly — real multi-tool use across 15 model turns, ending in a reasonable cited report. The full run is browsable on the [docs site](https://ryanbbrown.com/thinharness/examples). - -### 2. LongMemEval-V2 Reproduction - -I ran ThinHarness on a retrieval-heavy 127 question subset of a benchmark for long-term agent memory, and did a local reproduction of the benchmark's optimized harness on the same subset. - -- **Performance:** Matched-or-better accuracy (74.0% vs 72.4% on the 127 dynamic questions) with ~46% less token usage (62M vs. 116M). See [my fork](https://github.com/ryanbbrown/LongMemEval-V2) for more details. -- **Simpler Setup:** ThinHarness only had its built-in filesystem tools (with `jsonl_search` doing the heavy lifting), while the benchmark harness was a full Codex instance with shell and a custom Python tool designed for the task. - -### 3. Personal Opinions Agent - -An agent I run live for myself, inspired by [this Substack post](https://blog.kunchenguid.com/p/everyone-should-have-an-opinionsmd). On a schedule, it reads my Readwise highlights + surrounding context, draws on what it's learned in past runs, and proposes conceptual changes to a durable `OPINIONS.md`. - -Approval happens over Telegram, and it can be simple accept/reject or involve multi-turn revision + discussion. Under the hood it exercises filesystem tools over a JSONL corpus, native structured output, long-term memory, a custom validation tool, and resumable conversations that pick up across each round. See [here](https://github.com/ryanbbrown/opinions-agent) for the code. +## Size -## Status +These are source lines of code in the smallest first-party opinionated configuration for each harness. The count includes required first-party runtime packages and excludes tests, documentation, examples, hosted services, and unrelated optional interfaces where the project structure makes that separation possible. -Pre-1.0. APIs may shift, but I don't expect dramatic changes. Forking is a real option, not just a theoretical one: the codebase is small enough that pulling upstream changes into your fork by hand stays cheap. Each major feature (MCP, subagents, jsonl_search, parallel_llm, skills) lives in its own file with no hidden dependencies. If you don't use one, that's even less code to worry about. If you want to delete it entirely, that's a one-shot 10-word prompt to a coding agent. +| Harness | Source LOC | What is counted | +| --- | --- | --- | +| **ThinHarness** | **10,359** | 6,297 core + 4,062 bundled plugins and tools | +| [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) | 72,737 | The base and headless profiles + their first-party package dependency closure | +| [Pydantic AI Coder](https://github.com/pydantic/pydantic-ai-harness) | 85,633 | Pydantic AI runtime + the Coder capability and everything it composes | +| [Pi](https://github.com/earendil-works/pi) | 95,276 | `pi-coding-agent` + its first-party workspace dependency closure | +| [Deep Agents](https://github.com/langchain-ai/deepagents) | 118,977 | Deep Agents + its required LangChain and LangGraph runtime | +| [fx](https://github.com/vercel-labs/fx) | 379,826 | The native coding-agent runtime and its first-party source closure | -ThinHarness was built with coding agents, but isn't vibe-coded. I have used it, iterated on it, and reviewed its design + behavior. The [website](https://ryanbbrown.com/thinharness/) includes a [codebase explainer](https://ryanbbrown.com/thinharness/explainer) that has gone through many versions so my mental model of the codebase stays up-to-date. +LOC is not a quality or performance score. It measures how much framework code comes with the comparable agent configuration. Moving code from a core package into required plugins does not make that configuration smaller, so the table counts both. ## License -MIT. See [LICENSE](LICENSE). +MIT. See [LICENSE](LICENSE). \ No newline at end of file diff --git a/assets/thinharness-run-loop.drawio b/assets/thinharness-run-loop.drawio index 1f941cb..9eb88ad 100644 --- a/assets/thinharness-run-loop.drawio +++ b/assets/thinharness-run-loop.drawio @@ -88,7 +88,7 @@ - + @@ -118,7 +118,7 @@ - + diff --git a/assets/thinharness-run-loop.svg b/assets/thinharness-run-loop.svg index 6ca53e9..aa8d907 100644 --- a/assets/thinharness-run-loop.svg +++ b/assets/thinharness-run-loop.svg @@ -1,3 +1,3 @@ -
harness/runtime
provider session
decision
approval pause
tool path
final result
retry / stop
stream event
Typed event stream
StreamEvent emitted at each blue dot; contains kind, run_id, and event number
SETUP
ONE MODEL TURN
BRANCH HANDLING INSIDE THE WHILE LOOP
Entry point
Harness.stream()
core.py:305-374
outer stream flow
Create run objects
RunContext
StreamEmitter
core.py:405-418
per-run state
trace scope
Open session
new_session()
resume_session()
core.py:448-491
one active session
Loop entry
RunContext.advance_model()
runtime.py:268
limits, notices, tracing
provider request
Call provider session
ModelSession
providers.py:184
start()
continue_with_tools()
continue_with_user_text()
Model turn
ModelTurn
providers.py:56
text
tool calls
raw JSON
Resolve turn
resolve_turn_output()
turns.py:50
OutputTurnDecision
chooses next branch
Approval required?
_approval_required_calls()
turns.py:322
before tool batch executes
Final result
RunContext.finalize()
runtime.py:420
builds HarnessResult
fires run_end once
Approval pause
pause_for_approval()
runtime.py:367
stop_reason="approval_required"
pending approvals + resume_state
no tools executed
Run tools
ToolBatchExecutor.execute_batch()
tool_execution.py:63
runs ordinary tool calls
preserves model order
Return tool outputs
Build ToolOutput[]
providers.py:97
model-visible results
Retry decision
RunContext.retry_or_fail()
runtime.py:448
budget remains or stop
Stop / raise
core.py:499-510
HarnessError
limit, provider,
or validation
Corrective request
continue_with_tools()
continue_with_user_text()
asks model to fix output
interpret turn
final
no approval
requires approval
tool batch
schema retry
next iteration
budget remains
no retry
next iteration
Text is not SVG - cannot display
\ No newline at end of file +
harness/runtime
provider session
decision
approval pause
tool path
final result
retry / stop
stream event
Typed event stream
StreamEvent emitted at each blue dot; contains kind, run_id, and event number
SETUP
ONE MODEL TURN
BRANCH HANDLING INSIDE THE WHILE LOOP
Entry point
Harness.stream()
core.py:305-374
outer stream flow
Create run objects
RunContext
StreamEmitter
core.py:405-418
per-run state
trace scope
Open session
new_session()
resume_session()
core.py:448-491
one active session
Loop entry
RunContext.advance_model()
runtime.py:268
limits, notices, tracing
provider request
Call provider session
ModelSession
providers.py:184
start()
continue_with_tools()
continue_with_user_content()
Model turn
ModelTurn
providers.py:56
text
tool calls
raw JSON
Resolve turn
resolve_turn_output()
turns.py:50
OutputTurnDecision
chooses next branch
Approval required?
_approval_required_calls()
turns.py:322
before tool batch executes
Final result
RunContext.finalize()
runtime.py:420
builds HarnessResult
fires run_end once
Approval pause
pause_for_approval()
runtime.py:367
stop_reason="approval_required"
pending approvals + resume_state
no tools executed
Run tools
ToolBatchExecutor.execute_batch()
tool_execution.py:63
runs ordinary tool calls
preserves model order
Return tool outputs
Build ToolOutput[]
providers.py:97
model-visible results
Retry decision
RunContext.retry_or_fail()
runtime.py:448
budget remains or stop
Stop / raise
core.py:499-510
HarnessError
limit, provider,
or validation
Corrective request
continue_with_tools()
continue_with_user_content()
asks model to fix output
interpret turn
final
no approval
requires approval
tool batch
schema retry
next iteration
budget remains
no retry
next iteration
Text is not SVG - cannot display
\ No newline at end of file diff --git a/docs/behavior.md b/docs/behavior.md index 7ad40ae..2b9c136 100644 --- a/docs/behavior.md +++ b/docs/behavior.md @@ -63,6 +63,22 @@ Use this section only when ordering, lifecycle, concurrency, retries, streaming, - JSONL-TYPED-EQUALITY-4: Non-comparable row values do not match either `eq` or `ne` and increment `compare_warnings` once per candidate row where a typed equality comparison was attempted. - JSONL-TYPED-EQUALITY-5: Invalid typed equality filter definitions fail before scanning rows with `invalid where filter`. +## Image Content + +### Purpose + +ThinHarness accepts ordered text and local image content through one provider-neutral interface while keeping text-only behavior unchanged. + +### Requirements + +- IMAGE-CONTENT-1: `TextBlock`, `ImageBlock`, and `Prompt` are the public content interface. Prompts accept a non-empty string or a non-empty copied sequence of immutable blocks. Text and image data must be non-empty, image data must be `bytes`, and media types are limited to JPEG, PNG, GIF, and WebP. +- IMAGE-CONTENT-2: `Harness.run()`, `stream()`, and `run_sync()` normalize content before run hooks and the first provider request. Invalid caller prompts and invalid run-start or user-prompt hook replacements fail as `HarnessError` with the validation detail. Run-start and user-prompt hooks receive normalized block tuples and can replace them with a valid string or block sequence. Caller blocks, hook context, and harness notices remain in that order. +- IMAGE-CONTENT-3: Tool results accept a string, including an empty string, or a non-empty ordered block sequence. Empty and ordinary JSON lists remain ordinary JSON tool data. Invalid or non-serializable handler results become failed tool envelopes. Text-only provider payloads remain unchanged. Image-bearing provider payloads preserve execution outcome, metadata, tool-call id, content order, and images through provider-native OpenAI and Anthropic blocks. +- IMAGE-CONTENT-4: OpenRouter keeps a canonical JSON tool message for each result. Tool images are descriptors in that message and are projected after the parallel tool-message batch as labelled user content, with each label immediately before its image and any harness notice after all image parts. +- IMAGE-CONTENT-5: After-tool hook string fields contain canonical JSON, including base64 image fields, while the envelope is the structured mutation interface. Either mutation path is validated and synchronized within each hook's error policy. A non-strict invalid mutation is logged and rolled back; a strict invalid mutation raises `HarnessError`. Retry control flow, budget use, retry events, and retry span status use the pre-hook classification, while hooks can change model-visible output. These fields, completed results, and resume state can be sensitive and large. +- IMAGE-CONTENT-5A: Every tool-call record contains canonical structured `result` and string `output` fields. Image-bearing `output` is the canonical redacted projection, while `result` retains the complete image data once. +- IMAGE-CONTENT-6: Remote image URLs, image fetching, image generation, other media, conversion, OCR, image-bearing subagent tasks, and image-bearing `parallel_llm` prompts are not supported. Bash output always stays text and does not load image paths. Model image capability errors come from the selected provider or custom model, not model-name checks. + ## Resume State ### Purpose @@ -75,10 +91,122 @@ Built-in provider resume state is a self-contained, provider-agnostic transcript - RESUME-2: `resume_state` is self-contained and does not depend on provider continuation tokens such as OpenAI `previous_response_id`; an OpenAI run that never received a response id is still resumable. - RESUME-3: Resuming on the originating provider preserves native reasoning (Anthropic thinking signatures, OpenAI `encrypted_content`, OpenRouter `reasoning_details`); resuming on a different provider degrades each reasoning part to a leading ``-tagged text block and drops the opaque blob. Native re-emit additionally requires the resuming run to be able to accept the block: OpenAI re-emits the native reasoning item only when the resuming model is reasoning-capable, and Anthropic uses the thinking gate in RESUME-3A; otherwise both use the text fallback. So a reasoning-model capture resumed on a non-reasoning model of the same provider degrades to text. - RESUME-3A: Anthropic resume treats explicit `extra_body["thinking"]` as authoritative: `enabled` and `adaptive` accept signed thinking replay, while `disabled`, unknown, or malformed values suppress native replay. Without an explicit thinking key, `HarnessConfig.effort` implies adaptive thinking; otherwise Anthropic models outside the legacy off-by-default families (`claude-opus-4`, `claude-sonnet-4`, `claude-haiku-4`, and `claude-3`) are assumed to run thinking by default and keep signed thinking blocks on resume. -- RESUME-4: Built-in provider resume state uses `version` 3; version 1 and version 2 state and old provider-native `kind` values are rejected with a regenerate error. +- RESUME-4: Built-in provider resume state uses `version` 4. User and tool entries carry canonical ordered content blocks; image bytes are stored once as base64. A tool entry also stores nullable `wire_output` when its exact provider-facing text differs from the canonical result, and replay uses that text byte-for-byte. Older state and old provider-native `kind` values are rejected with a regenerate error. An approval envelope that contains version 3 provider state fails with `approval state provider_state version 3 is not supported`. - RESUME-5: On resume, the live system prompt from the resuming harness config is re-injected; captured system prompts are not stored or restored. - RESUME-6: A session seeded via `OpenAIResponsesSession.start(prompt, constants, previous_response_id=...)` captures only new transcript entries, so externally seeded prior turns are not present when later resumed from `resume_state`. This is unrelated to reasoning fidelity and is not changed by RESUME-3/RESUME-7. -- RESUME-7: For reasoning-capable OpenAI Responses models the harness requests `include=["reasoning.encrypted_content"]` so reasoning survives resume; non-reasoning models are unaffected. Captured `resume_state` therefore contains encrypted reasoning blobs (OpenAI/OpenRouter) and signed thinking (Anthropic) and should be treated as sensitive, consistent with the local-trace sensitivity note. +- RESUME-7: For reasoning-capable OpenAI Responses models the harness requests `include=["reasoning.encrypted_content"]` so reasoning survives resume; non-reasoning models are unaffected. Captured `resume_state` can contain image bytes with base64 overhead, encrypted reasoning blobs (OpenAI/OpenRouter), and signed thinking (Anthropic), so it must be treated as sensitive and potentially large. +- RESUME-8: Cross-provider and same-provider resume preserve text/image order and exact image bytes. Same-provider replay continues to preserve accepted native reasoning data. + +## Plugin Composition + +### Purpose + +Callers compose optional harness behavior explicitly while independent custom tools and hooks stay direct constructor inputs. + +### Requirements + +- PLUGIN-1: `Harness` accepts plugins in caller order through `plugins=`; no plugin is loaded through entry points, directories, manifests, or implicit defaults. +- PLUGIN-2: Plugin names are non-empty and unique within one harness. A duplicate name fails before either plugin binds. +- PLUGIN-3: `PluginContext` contains the canonical harness root, configured model, and a narrow `ChildHarnessHost`; it does not expose the parent harness. Plugin binding is synchronous and performs no file, provider, or network I/O. Static tools, instructions, hooks, and statically knowable child-recipe rules are validated and visible immediately after harness construction. A delegation plugin registers its tool and recipes through the child host during binding. +- PLUGIN-4: `Harness.connect()` or the first run opens connected plugin bindings once in caller order. Concurrent connection calls share that attempt, and connection completes before `run_start` hooks fire. +- PLUGIN-5: Dynamic tools, instructions, and hooks are staged and receive the same complete validation as static contributions. ThinHarness commits the full dynamic set only after every binding opens successfully. +- PLUGIN-6: A connection failure, including cancellation, closes entered bindings in reverse order, installs no dynamic contribution, and leaves connection retryable. `run_start` and `run_end` do not fire for an attempt that fails during connection. +- PLUGIN-7: Closing a harness closes plugin bindings in reverse order before closing a model owned by the harness. Repeated close calls have no effect. +- PLUGIN-8: Contribution order is plugin static contributions, direct `tools=` and `hooks=`, then plugin dynamic contributions. System instructions are the configured system prompt, plugin instructions in caller plugin order, and all per-tool instructions; structured-output instructions are added through the existing output path. A skill summary is an ordinary plugin instruction at the `SkillsPlugin` position. `SubagentsPlugin` contributes its ordinary `subagent` tool at its plugin position. In a child, all plugin tools precede all direct tools: automatically inherited plugins keep parent plugin order, explicit child plugins follow them, inherited direct tools follow all plugins, and explicit child tools are last. +- PLUGIN-9: ThinHarness copies caller-supplied hook registries before adding plugin hooks. Plugin composition never mutates a caller-owned registry. +- PLUGIN-10: Plugins are trusted in-process code. ThinHarness does not isolate them or resolve dependencies between them. +- PLUGIN-11: The parent child host builds one ordered unique agent catalog from recipe names registered during synchronous plugin binding. Blank names fail, while repeated names across delegation tools share one catalog entry. Core seals registration after all plugins bind and before validating agent-filtered hooks; registration during connection or execution fails without changing delegation provenance, recipes, or names. +- PLUGIN-11A: A delegation tool registered during binding contributes its recipe names to the static catalog even when the tool itself is contributed during connection. Connected contributions cannot change the catalog. A runtime child request must use a name in the sealed catalog. +- PLUGIN-12: Core records authoritative direct, plugin, and delegation composition roles independently of caller-visible `ToolOrigin`; these records control inheritance and delegation tracing and cannot be forged through tool metadata. +- PLUGIN-13: Automatic child inheritance is explicit and structural. Only a plugin with synchronous `for_child()` is rebound against the child context; its returned plugin must be valid and keep the expected fixed name. `for_child()` can run during parent construction and later child construction, so it must be repeatable and side-effect-free. I/O-free `bind()` can also run during parent construction for a child that borrows the parent model. An override-model child's plugins bind only after the real child model exists. + +## Bash Plugin + +### Purpose + +Callers explicitly add one bounded, non-interactive local Bash tool through plugin composition. + +### Requirements + +- BASH-PLUGIN-1: A plain harness has no Bash tool. `BashPlugin` has the fixed name `"bash"` and contributes one sequential tool named `bash`; normal plugin and tool collision rules apply. +- BASH-PLUGIN-2: Bash uses the canonical harness root. A model-selected working directory must resolve to an existing directory inside that root. +- BASH-PLUGIN-3: Each call starts a fresh non-interactive `bash -c` process with no stdin or shared shell state. Persistent background work is not supported. After the direct shell exits, times out, or is cancelled, the plugin performs bounded, best-effort process-group cleanup and final output drain. A descendant that leaves the process group can escape termination, and signalling after shell exit has an unavoidable process-group-ID reuse race. +- BASH-PLUGIN-4: The host controls default and maximum command timeouts. Run cancellation signals the process group, completes bounded cleanup after process handoff despite repeated cancellation, and then propagates cancellation. Cancellation during process creation waits for handoff and cleans up any created process before it propagates. +- BASH-PLUGIN-5: Stdout and stderr are drained concurrently into separate bounded head-and-tail buffers without spill files. Each buffer retains `ceil(limit / 2)` bytes from the start and `floor(limit / 2)` bytes from the end, and inserts `\n... {omitted_bytes} bytes omitted ...\n` between them when truncated. +- BASH-PLUGIN-6: Minimal environment mode inherits only `PATH`, `HOME`, temporary-directory, locale, and timezone values. Full host-environment inheritance is explicit. Both modes remove inherited `BASH_ENV` and `ENV`, set non-interactive defaults, and then apply host-configured environment values. +- BASH-PLUGIN-7: Bash calls execute sequentially. Optional approval uses the existing top-level approval flow. +- BASH-PLUGIN-8: `BashPlugin` does not inherit automatically into children. A child that needs Bash must list its own plugin, subject to existing child approval rules. +- BASH-PLUGIN-9: Bash support is POSIX-only, requires process groups, runs `bash` from `PATH`, and has no shell fallback. +- BASH-PLUGIN-10: Local Bash is not a sandbox. Working-directory containment and environment filtering do not restrict absolute paths, network access, host files, or other host authority. + +## Subagents Plugin + +### Purpose + +Callers add delegation explicitly through `SubagentsPlugin`, which owns the model-facing tool, child recipes, inheritance policy, child hooks, and delegation result shaping. + +### Requirements + +- SUBAGENTS-PLUGIN-1: A plain harness has no delegation tool. `SubagentsPlugin` has fixed name `"subagents"`, contributes one ordinary tool named `subagent`, and provides an unnamed `"default"` child plus ordered optional named children. Normal tool and plugin collision rules apply; no tool name is reserved. +- SUBAGENTS-PLUGIN-2: The unnamed child uses parent-derived model, prompt, limits, output, and additive inheritance defaults. Named configurations can override the model, prompt, limits, output, hooks, plugins, and tools; named children with no tools are valid. +- SUBAGENTS-PLUGIN-3: `inherit_parent=True` rebinds only plugins that explicitly implement `for_child()` and inherits eligible direct tools from the active run snapshot. Inherited sources keep parent order, explicit child plugins and tools follow them, and duplicates fail rather than replace inherited values. Approval-required tools never enter a child. For a child that borrows the parent model, known plugin-tool violations fail during parent construction. For an override-model child, plugin-tool validation runs when the real child is constructed. +- SUBAGENTS-PLUGIN-4: Child hooks belong to the selected child configuration. Default-child hooks come from `default_hooks`; named hooks come from `SubAgentConfig.hooks`. Parent `before_subagent_run` and `after_subagent_run` hooks remain on the parent, identify that parent through `HookContext.harness`, and can filter against the sealed child-host agent catalog. +- SUBAGENTS-PLUGIN-5: Every top-level plugin context receives a narrow child host that accepts delegation only during an active parent tool call. The active-call lease is shared across copied async contexts and becomes invalid when that tool call ends, so detached tasks cannot start children later. Every child context receives a disabled host, and `SubagentsPlugin` is invalid in explicit child plugins, so children cannot create grandchildren through built-in or custom plugin paths. +- SUBAGENTS-PLUGIN-6: Each child is a fresh, independently budgeted run with no parent transcript. A parent-model child borrows the model; an override creates and closes its own model while projecting parent request settings and only same-provider credentials. +- SUBAGENTS-PLUGIN-7: Child events remain nested unless subagent streaming is enabled, then preserve parent run and tool-call correlation. Real delegation is traced from authoritative composition with `subagent.delegation=true` from tool-span start; a same-named direct tool or forged `ToolOrigin` remains an ordinary tool event. +- SUBAGENTS-PLUGIN-8: Successful results preserve agent, inheritance mode, effective tools, child request usage, and structured-output metadata. Unknown agents, provider failures, hook failures, strict sibling cancellation, and parent cancellation preserve existing error and cleanup behavior; every created child closes without hiding the original error. +- SUBAGENTS-PLUGIN-9: Child configuration, child hosts, plugin state, and model objects never enter resume or approval state. Parent usage counts one delegation tool call while child requests and tokens remain child usage. +- SUBAGENTS-PLUGIN-10: `ChildHarnessRequest.tool_mode` is the sole child-inheritance state: `"inherited"` selects default or inherited-only composition, `"inherited+explicit"` selects additive inherited and explicit sources, and `"explicit"` disables parent inheritance. Hook contexts and delegation result metadata expose the boolean inheritance value derived from that mode. + +## Filesystem Plugin + +### Purpose + +Callers opt into root-scoped workspace tools without making filesystem behavior part of the core harness. + +### Requirements + +- FILESYSTEM-PLUGIN-1: `Harness` has no implicit filesystem tools. `FilesystemPlugin` provides `read`, `write`, `edit`, `search`, `list`, and `glob` by default; callers select an ordered subset explicitly. +- FILESYSTEM-PLUGIN-2: `jsonl_search` and `read_image` are opt-in tools of `FilesystemPlugin`. `read_image` shares the read path policy but is not part of the default tool set and does not change the text-only `read` tool. +- FILESYSTEM-PLUGIN-3: `HarnessConfig.root` is the one run root. `FilesystemPlugin` uses that root and cannot configure a different root. +- FILESYSTEM-PLUGIN-4: Harness construction and plugin binding do not create the workspace root. A harness without `FilesystemPlugin` has a generic default prompt, adds no workspace-root instruction, and has no workspace filesystem side effect. Observability sinks keep their independent configured storage behavior. +- FILESYSTEM-PLUGIN-5: Filesystem limits, output location, search settings, and path policies belong to `FilesystemPlugin`. +- FILESYSTEM-PLUGIN-6: Independent custom tools continue to use `tools=[ToolSpec(...)]`; callers do not need to wrap one tool in a plugin. +- FILESYSTEM-PLUGIN-7: `FilesystemPlugin` has the runtime-fixed name `"filesystem"`. Its constructor configuration is frozen: mutation of constructor inputs, returned property values, or plugin attributes cannot change later parent or child bindings. +- FILESYSTEM-PLUGIN-8: `read_image` reads at most `max_image_bytes + 1` bytes from one regular local file, validates complete minimum JPEG, PNG, GIF, or WebP signatures, and returns metadata text followed by one image block. It rejects missing files, non-regular files, unreadable files, path or symlink escapes, unknown formats, SVG, truncated headers, and files over `max_image_bytes`; harmless trailing JPEG or WebP data is accepted. +- FILESYSTEM-PLUGIN-9: `max_image_bytes` is a positive frozen setting that defaults to 5,000,000 and is independent of `max_read_bytes`; exactly the limit is accepted. Plugin binding performs no image I/O. + +## Skills Plugin + +### Purpose + +Callers explicitly compose a fixed skill catalog and select which skill operations a harness can use. + +### Requirements + +- SKILLS-PLUGIN-1: A harness accepts at most one runtime-fixed `SkillsPlugin` named `"skills"`. The plugin requires one or more ordered skill directories and an explicit non-empty ordered selection of `skill_read`, `skill_run`, or both. +- SKILLS-PLUGIN-2: The plugin discovers and validates its catalog during construction. Its selected tools and summary are static and visible immediately after harness construction, and binding performs no I/O. +- SKILLS-PLUGIN-3: Relative skill directories resolve from the process working directory, not from `HarnessConfig.root`. Reusing one plugin object across harnesses reuses the same registry and catalog. +- SKILLS-PLUGIN-4: Catalog names, paths, metadata, selection, and summary are frozen at plugin construction. The public registry returns deep detached catalog values, so mutations cannot change later parent or child behavior. Existing skill content, file trees, and scripts remain live and are read or executed when a tool is invoked. A new plugin is required to discover added or removed skills. +- SKILLS-PLUGIN-5: A non-empty catalog contributes selected tools in caller order and one compact summary. The summary mentions `skill_read` only when that tool is selected. A catalog with no skills contributes no tools or summary. +- SKILLS-PLUGIN-6: `skill_read` preserves live content, tree, containment, and truncation behavior and is parallel-safe. `skill_run` preserves runner, working-directory, merged-output, timeout, metadata, and containment behavior and runs sequentially. +- SKILLS-PLUGIN-7: `SkillsPlugin` explicitly opts into safe child inheritance and rebinds through the generic plugin contract. Inherited bindings share the exact constructor-time registry, frozen catalog, tool order, and one summary without another discovery pass. Its constructor configuration is frozen so later input, property-value, or attribute mutation cannot change a binding. + +## Parallel LLM Plugin + +### Purpose + +Callers explicitly compose a stateless parallel batch tool and choose whether it borrows a model or owns per-call provider construction. + +### Requirements + +- PARALLEL-LLM-PLUGIN-1: A harness accepts at most one runtime-fixed `ParallelLlmPlugin` named `"parallel_llm"`. It contributes one text-only `parallel_llm` tool with no model-visible model override. +- PARALLEL-LLM-PLUGIN-2: With no model argument, the plugin borrows the model from the context where it is bound, so an inherited binding uses the child model. With a model object, it borrows that caller-owned object. The plugin does not close either borrowed model. Constructor configuration is frozen for parent and inherited bindings. +- PARALLEL-LLM-PLUGIN-3: A string model uses plugin-owned provider and request settings, creates a provider for each batch invocation, and closes it after success, schema-resolution failure, request failure, or cancellation. Provider and request settings are rejected for borrowed models. +- PARALLEL-LLM-PLUGIN-4: The plugin uses the canonical harness root. Read and write policies are root-scoped, outputs are atomic JSON files, prompt count and concurrency are bounded, and ordered sparse results report batch-local request, total, success, and failure counts. +- PARALLEL-LLM-PLUGIN-5: Batch prompts use independent fresh sessions and receive no parent system prompt, tools, memory, or continuation. Batch requests and tokens are outside parent `RunUsage` and `max_model_requests`; the parent counts one batch invocation toward `max_tool_calls`. +- PARALLEL-LLM-PLUGIN-6: Provider transport retries remain inside one logical batch request. Cancellation propagates and closes plugin-owned string-model providers. +- PARALLEL-LLM-PLUGIN-7: Callers that need a renamed tool or structured batch output use `ParallelLlmTool(...).spec()` directly. Direct specifications use the same ordinary `ToolSpec` contract as all other tools. ## Run Toolset Freeze @@ -88,9 +216,9 @@ The set of tools a model can call is fixed when a run starts, so every provider ### Requirements -- TOOLSET-FREEZE-1: The run's tool schemas, system instructions, request metadata, and structured-output request are captured once per run after run-start hooks and MCP connection, and every provider request in that run uses that captured set. -- TOOLSET-FREEZE-2: A tool added with `add_tool` during an in-flight run does not appear in that run's later provider requests; it takes effect on the next run. -- TOOLSET-FREEZE-3: The executable tool map is frozen with the schemas: a model call naming a tool added mid-run resolves as an unknown tool for the current run, and approval-required detection uses the same frozen map. +- TOOLSET-FREEZE-1: The run's tool schemas, executable map, authoritative direct/plugin/delegation composition roles, system instructions, request metadata, and structured-output request are captured once per run after harness connection and run-start hooks. Every provider request and delegated child in that run uses that snapshot. +- TOOLSET-FREEZE-2: A direct tool added with `add_tool` during an in-flight run does not appear in that run's later provider requests or delegated children; it takes effect on the next run. +- TOOLSET-FREEZE-3: A model call naming a tool added mid-run resolves as an unknown tool for the current run. Approval detection, direct-tool inheritance, and delegation tracing use the same frozen authoritative map rather than live tools or caller-supplied origins. ## Run Token Accounting @@ -128,6 +256,7 @@ Provider-neutral request settings let callers tune output length and reasoning d - PROVIDER-SETTINGS-3: `HarnessConfig.effort` and `ModelSettings.effort` pass through as provider-neutral strings. OpenAI Responses and OpenRouter send `reasoning: {"effort": ...}`; Anthropic sends `output_config.effort` and injects adaptive thinking unless `extra_body` supplies its own top-level `thinking` key. - PROVIDER-SETTINGS-4: The harness does not client-validate provider/model-specific `effort`, `temperature`, or thinking combinations. Invalid combinations surface as provider API errors. - PROVIDER-SETTINGS-5: `HarnessConfig.request_timeout` applies independently to every built-in provider transport attempt, including retry attempts. +- PROVIDER-SETTINGS-6: Anthropic request metadata includes only a string `user_id`, the sole metadata field accepted by the Messages API. Other run metadata remains available to harness hooks, tracing, and child correlation but is not sent in the Anthropic payload. ## Provider Request Retries @@ -142,7 +271,7 @@ Built-in provider requests recover from transient HTTP failures without repeatin - PROVIDER-RETRY-3: Retry delay uses `request_retry_backoff * 2**retry_index` plus up to 25 percent positive jitter. A valid numeric or HTTP-date `Retry-After` can increase that delay, and every delay is capped at 60 seconds. - PROVIDER-RETRY-4: Cancellation during a request or delay propagates immediately. Exhaustion raises the final attempt's `ProviderError`, preserving provider-error run classification. - PROVIDER-RETRY-5: Transport attempts stay inside one logical model request. They do not increase model request limits, usage counts, stream event counts, trace span counts, parallel completion request counts, or provider session history. -- PROVIDER-RETRY-6: Named subagent override models and inferred parallel completion models inherit the parent request retry settings. The parallel LLM tool has no separate provider retry loop or attempt budget. +- PROVIDER-RETRY-6: Named child override models inherit the parent request retry settings. A `ParallelLlmPlugin` with a string model uses its own request retry settings; one rebound with no model uses the child model settings; one configured with a model object uses that model's settings. The parallel LLM tool has no separate provider retry loop or attempt budget. - PROVIDER-RETRY-7: Retries use at-least-once HTTP delivery. A transport failure after provider acceptance can cause duplicate provider work or charges because built-in providers do not share a portable idempotency-key contract. - PROVIDER-RETRY-8: A custom `http_client` can apply its own retry policy below the provider retry loop. Callers set `request_retries=0` when the custom client owns retries to avoid multiplying attempt budgets. @@ -174,21 +303,24 @@ Tracing and streaming expose projections of the same neutral per-request model-v - MODEL-OBSERVABILITY-6: Core tracing emits OTel/GenAI-oriented attributes and does not include sink-specific display namespaces. - MODEL-OBSERVABILITY-7: Model spans pin `gen_ai.usage.input_tokens`, `gen_ai.usage.output_tokens`, `gen_ai.usage.cache_read.input_tokens`, `gen_ai.usage.total_tokens`, `gen_ai.response.model`, and `gen_ai.response.finish_reasons`. `cache_read.input_tokens` carries provider-reported cached input tokens when present and is omitted when unreported. `finish_reasons` is always a list wrapping the normalized reason. `total_tokens` passes through a raw provider `total_tokens` when present and is otherwise computed as input+output only when both are present; partial usage yields no total. - MODEL-OBSERVABILITY-8: Custom `Model` implementations that do not populate normalized `ModelTurn` usage fields keep their `gen_ai.usage.*` span attributes via best-effort extraction from the raw response. +- MODEL-OBSERVABILITY-9: Trace attributes and non-terminal progress events never contain image bytes, base64, or data URLs. Multimodal prompt and tool-result values use compact ordered JSON with visible text and image descriptors containing media type, byte size, and zero-based block index; existing field types and text-only values stay unchanged. +- MODEL-OBSERVABILITY-10: Provider, hook, and span errors redact image encodings restored from resume or approval state as well as caller prompts, tool results, valid hook replacements, and complete image data URLs. Redacted provider failures keep `ProviderError` classification and status code in public events and spans while recording only the sanitized message. Failures that need no redaction preserve their original exception type. Provider adapters do not include serialized request bodies in error messages. +- MODEL-OBSERVABILITY-11: `ToolCallCompletedEvent.message` remains plain text. The agent span records the redacted normalized caller prompt, while the first model span records the effective post-hook prompt. Approval resume adds no prompt annotation. `RunCompletedEvent.result` remains identical to the complete run result and can contain sensitive, large image-bearing records and resume state. ## MCP Client Layer ### Purpose -ThinHarness exposes tools from MCP servers through wrapper objects whose transport, session, and connection sharing come from the FastMCP client, while ThinHarness keeps tool selection, conversion, error envelopes, and trace attribution. +ThinHarness exposes tools from MCP servers through explicit `MCPPlugin` composition. Server wrappers retain FastMCP transport, session, and connection sharing, while the plugin owns harness binding, discovery, lifecycle, and tool attribution. ### Requirements -- MCP-1: `MCPServer` accepts exactly one FastMCP `ClientTransport`, including `FastMCPTransport` for an MCP server object in the same Python process; URL strings, script paths, server objects, and configuration dictionaries are rejected with `TypeError`. The stdio, SSE, and Streamable HTTP compatibility wrappers take command- or URL-based constructors and derive their ids from them; the generic class derives its default id from the transport class name, and duplicate ids get `-2`, `-3` suffixes. -- MCP-2: Connections open lazily on `Harness.connect()` or the first run; one discovered tool snapshot is reused for all runs of one harness; `Harness.aclose()` closes harness-entered servers, and a partial multi-server connection failure closes servers that were already opened. -- MCP-3: A wrapper owns the FastMCP client built on its transport: nested and concurrent entries share one connection, the final exit closes the transport (terminating a stdio child process), and the same wrapper can reconnect afterwards. One stateful transport object must not be reused across wrappers; reusing the same wrapper shares one session. +- MCP-1: `MCPServer` accepts exactly one FastMCP `ClientTransport`, including `FastMCPTransport` for an MCP server object in the same Python process; URL strings, script paths, server objects, and configuration dictionaries are rejected with `TypeError`. The stdio, SSE, and Streamable HTTP compatibility wrappers take command- or URL-based constructors and derive their public base ids from them; the generic class derives its base id from the transport class name. Each `MCPPlugin` binding resolves duplicate ids locally with deterministic `-2`, `-3` suffixes without mutating shared wrappers. +- MCP-2: MCP is enabled only by adding one fixed-name `MCPPlugin` to `Harness(plugins=...)`. Generic plugin connection opens servers lazily on `Harness.connect()` or the first run, discovers one tool snapshot per binding, and reuses that snapshot for all runs. The complete discovered contribution is validated and installed atomically. A connection, discovery, validation, or cancellation failure closes entered servers in reverse order, installs no tools, and permits retry; `Harness.aclose()` closes the plugin once. +- MCP-3: A wrapper owns the FastMCP client built on its transport: nested and concurrent entries share one connection, the final exit closes the transport (terminating a stdio child process), and the same wrapper can reconnect afterwards. One stateful transport object must not be reused across wrappers; reusing the same wrapper across parent, child, or independent harness bindings shares one reference-counted session. - MCP-4: Final close is bounded — the bound comes from FastMCP's `client_disconnect_timeout` setting (default 5 seconds) — and a caller cancellation consumed by transport cleanup is re-raised after cleanup completes. Cancelling a first connection or a final close propagates the cancellation and leaves the wrapper reusable. -- MCP-5: `include_tools` and `exclude_tools` match original MCP tool names before prefixing and normalization; `tool_prefix`, schema cleanup, sanitized-name collision errors, and cross-harness tool collision errors are ThinHarness behavior. Discovered MCP tools are ordinary `ToolSpec` objects with `kind="mcp"` and `McpToolInfo` attribution, and tracing reads that attribution from the `ToolSpec`, so an after-tool hook cannot erase it. -- MCP-6: Successful `structuredContent` is returned as a JSON string; text, image, audio, embedded-resource, and resource-link blocks convert in order to model-visible text. A protocol-level tool failure (`isError`) returns a failed `ToolResult` with `error_type="MCPToolError"` and `retry=True`; known transport and protocol failures during a tool call return `error_type="MCPError"`, including when wrapped in an exception group or explicit cause chain — a group whose members are all `Exception`s is normalized when any member's cause chain holds a known failure, even alongside sibling exception noise from teardown. An exception group carrying cancellation or any other non-`Exception` failure propagates, and exceptions with no known failure in their group or cause chain propagate as programming errors. -- MCP-7: MCP tools and connection details never enter resume state, and subagents see MCP servers only through explicit `inherit_mcp_servers` and `mcp_servers` settings. -- MCP-8: The base install works without MCP packages: importing ThinHarness and constructing any wrapper needs no extra, and opening a connection without `mcp` or `fastmcp` raises `MCPDependencyError` with the `thinharness[mcp]` install hint. +- MCP-5: `include_tools` and `exclude_tools` match original MCP tool names before prefixing and normalization; `tool_prefix`, schema cleanup, sanitized-name collision errors, and cross-contribution tool collision errors are ThinHarness behavior. Discovered MCP tools are ordinary `ToolSpec` objects with `ToolOrigin(plugin="mcp", source=resolved_server_id, attributes={"tool_name": original_tool_name})`. Tracing reads this origin from the `ToolSpec`, so an after-tool hook cannot erase attribution. Model-visible result metadata uses the same binding-local server id. +- MCP-6: Successful supported MCP image blocks with valid base64 remain ordered image blocks. Without `structuredContent`, text and image blocks keep their order and non-image media become placeholders. With `structuredContent`, its canonical JSON is the first text block, all MCP text, audio, resource, and resource-link blocks are discarded, and only images plus image placeholders keep their original relative order. Malformed or unsupported images become text placeholders. A protocol-level tool failure (`isError`) returns a text-only failed `ToolResult` with `error_type="MCPToolError"` and `retry=True`; known transport and protocol failures during a tool call return `error_type="MCPError"`, including when wrapped in an exception group or explicit cause chain — a group whose members are all `Exception`s is normalized when any member's cause chain holds a known failure, even alongside sibling exception noise from teardown. An exception group carrying cancellation or any other non-`Exception` failure propagates, and exceptions with no known failure in their group or cause chain propagate as programming errors. +- MCP-7: MCP tools and connection details never enter resume state. `MCPPlugin` does not inherit automatically into children. A child that needs MCP lists an explicit `MCPPlugin` in its plugin configuration; that child binding owns its connection lifecycle, while reuse of the same server wrapper keeps the wrapper's reference-counted session behavior. +- MCP-8: The base install works without MCP packages: importing ThinHarness and constructing any wrapper or `MCPPlugin` needs no extra, and opening a connection without `mcp` or `fastmcp` raises `MCPDependencyError` with the `thinharness[mcp]` install hint. - MCP-9: `timeout` bounds MCP initialization and HTTP connection establishment; `read_timeout` bounds MCP requests, HTTP reads, and SSE reads. diff --git a/docs/docs.md b/docs/docs.md index f98805b..fa42f6f 100644 --- a/docs/docs.md +++ b/docs/docs.md @@ -2,8 +2,6 @@ ThinHarness is a small SDK for purpose-built agent loops. The host application chooses the model, tools, limits, context, output contract, and lifecycle hooks. The model gets enough room to plan and use tools, but the run stays bounded by configuration. -For code ownership and the run-loop mental model, see `docs/site/explainer.html`. This file is the user-facing API guide. - ## Install ```bash @@ -24,11 +22,14 @@ ThinHarness requires Python 3.11+. ```python import asyncio -from thinharness import Harness, HarnessConfig +from thinharness import FilesystemPlugin, Harness, HarnessConfig async def main() -> None: - async with Harness(HarnessConfig(root=".", model="openai:gpt-5.5")) as harness: + async with Harness( + HarnessConfig(root=".", model="openai:gpt-5.5"), + plugins=[FilesystemPlugin(tools=["read"])], + ) as harness: result = await harness.run("Read README.md and summarize it.") print(result.text) @@ -59,9 +60,9 @@ Streaming is coarse turn/tool/run streaming, not token-delta streaming. Provider Stream events are high-level workflow events intended for app consumption: -- `RunStartedEvent.prompt` includes the submitted prompt. +- `RunStartedEvent.prompt` includes the submitted text, or compact ordered JSON with redacted image descriptors for a multimodal prompt. - `ToolCallStartedEvent.arguments` includes the model-requested tool arguments. -- `ToolCallCompletedEvent.output` includes model-visible tool output. +- `ToolCallCompletedEvent.output` includes the exact text-only tool output or a compact canonical redacted projection for image-bearing output. - Raw provider response JSON is not part of stream events; use `HarnessResult.responses` for raw provider responses after completion. - `ModelMessageEvent.text` includes assistant text from the completed provider turn. - Child subagent events are flattened by default; set `include_subagents=False` to keep only the parent `subagent` tool lifecycle. @@ -88,23 +89,38 @@ config = HarnessConfig( system_prompt="You are a focused research agent.", max_model_requests=32, max_tool_calls=80, - read_paths=["inputs", "docs"], - write_paths=["outputs"], ) ``` Important groups: -- `root`, `read_paths`, `write_paths`, and `output_dir` define filesystem scope. +- `root` defines the run root. `FilesystemPlugin` owns filesystem paths, limits, search settings, and output location. - `model`, `api_key`, `base_url`, `temperature`, `max_tokens`, `effort`, `extra_body`, `request_timeout`, `request_retries`, and `request_retry_backoff` define provider settings. -- `builtin_tools`, `tools`, `subagents`, `mcp_servers`, and `skills_dir` define the model-callable surface. +- The `Harness` constructor's ordered `plugins=` and direct `tools=` inputs define the complete model-callable surface. ThinHarness has no implicit or selected built-in tool path. Filesystem, Bash, MCP, skills, parallel LLM, and subagent delegation use explicit plugins. - `max_model_requests`, `max_tool_calls`, `output_retries`, and `tool_retries` bound the run. - `output_type` and `output_mode` define structured output. - `tracing`, `local_tracing`, and `local_trace_dir` define observability. -## Built-In Filesystem Tools +## Plugins + +A plugin is a configured bundle of tools, system instructions, and hooks. Pass plugins explicitly in caller order: + +```python +harness = Harness( + HarnessConfig(root="."), + plugins=[FilesystemPlugin()], + tools=[custom_tool], + hooks=[custom_hook], +) +``` + +Independent custom tools and hooks stay direct constructor inputs. Plugin names must be unique within one harness. `PluginContext` exposes only the canonical root, configured model, and a narrow child-harness host. ThinHarness binds static contributions during construction, then opens connected plugins on `Harness.connect()` or before the first run. Connection is atomic: a failure installs no dynamic contribution, closes opened plugins in reverse order, and allows retry. Closing the harness also closes plugins in reverse order. A plugin opts into automatic child rebinding only by implementing `for_child()`. + +Plugins are trusted in-process code. ThinHarness does not discover them from entry points or directories, isolate them, resolve dependencies between them, or hot reload them. + +## Filesystem Plugin -When `builtin_tools` is omitted, the model gets these filesystem tools: +The core harness has no implicit filesystem tools. Add `FilesystemPlugin()` to get these default tools: - `read`: read bounded UTF-8 file ranges with line numbers. - `write`: create, overwrite, or append UTF-8 files. This tool is sequential. @@ -113,15 +129,24 @@ When `builtin_tools` is omitted, the model gets these filesystem tools: - `list`: list files or directories. - `glob`: find files by glob pattern. -When `builtin_tools` is provided, it is an explicit replacement list. Include every built-in tool the model should see. +Use the plugin's ordered `tools` list to select a different surface. `jsonl_search` and `read_image` are opt-in. `read_image` reads at most `max_image_bytes + 1` bytes from a regular JPEG, PNG, GIF, or WebP file under the read path policy and returns metadata text followed by the image. It does not change the text-only `read` tool. Configure its independent positive byte limit with `max_image_bytes` (default 5,000,000). -`jsonl_search` is available as an opt-in built-in: +For example: ```python -harness = Harness(HarnessConfig( - root=".", - builtin_tools=["read", "search", "jsonl_search"], -)) +harness = Harness( + HarnessConfig(root="."), + plugins=[FilesystemPlugin(tools=["read", "read_image"])], +) +``` + +`jsonl_search` is also opt-in: + +```python +harness = Harness( + HarnessConfig(root="."), + plugins=[FilesystemPlugin(tools=["read", "search", "jsonl_search"])], +) ``` Use `query` as a ripgrep row prefilter, `fields` to project only the values the model needs, and `where` for structured filters over jq-style field paths: @@ -182,18 +207,42 @@ For large multiline string fields, `field_searches` returns matching internal li Filesystem tools enforce the configured read and write policies. Paths must resolve under `root`; escape attempts through absolute paths outside `root`, `..`, or symlinks are rejected. ```python -harness = Harness(HarnessConfig( - root="/repo", - read_paths=["src", "tests"], - write_paths=["outputs"], -)) +harness = Harness( + HarnessConfig(root="/repo"), + plugins=[FilesystemPlugin( + read_paths=["src", "tests"], + write_paths=["outputs"], + )], +) ``` With this configuration, `read` can access `src/app.py` and `tests/test_app.py`, but not `docs/notes.md`. `write` can create or update `outputs/report.md`, but not `src/generated.py`. Omit `read_paths` or `write_paths` to allow that operation anywhere under `root`. +## Bash Plugin + +`BashPlugin` explicitly adds one sequential, non-interactive `bash` tool. A plain harness has no Bash tool. The plugin uses `HarnessConfig.root`; the model can select only an existing cwd contained by that canonical root. + +```python +from thinharness import BashPlugin, Harness, HarnessConfig + + +harness = Harness( + HarnessConfig(root="."), + plugins=[BashPlugin()], +) +``` + +Each call starts a fresh `bash -c` process with no stdin, PTY, shared shell state, or persistent background-job interface. The host configures default and maximum timeouts and a byte limit for each output stream. The model can request only a timeout, which is capped by the host. Stdout and stderr are drained concurrently into separate bounded head-and-tail buffers; omitted middle bytes get a visible marker and are not written to spill files. + +By default, commands inherit only `PATH`, `HOME`, temporary-directory, locale, and timezone values, plus fixed non-interactive defaults. Set `inherit_env=True` to copy the full host environment. In either mode, inherited `BASH_ENV` and `ENV` are removed before explicit host `env` values are applied. + +Timeout and run cancellation signal the command process group, allow one second for termination, and escalate to `SIGKILL`. Cleanup and final pipe drain are bounded, and cancellation then propagates. Normal shell exit also performs best-effort same-group descendant cleanup. A descendant that starts a new session can escape termination, and signalling after direct shell exit has an unavoidable process-group-ID reuse race. Bash is POSIX-only, runs `bash` from `PATH`, does not fall back to another shell, and does not inherit automatically into child harnesses. Configure `requires_approval=True` only for a top-level harness. + +Cwd containment and environment filtering reduce mistakes; they are not a sandbox. Commands can use absolute paths, network access, host files, and other authority available to the local process. Use Bash to explore workflow shape, then promote repeated actions into typed tools. + ## Custom Tools -Custom tools are registered as `ToolSpec` objects. A handler may return a `ToolResult`, a string, or JSON-serializable data. The model always receives a JSON envelope with `ok`, `content`, and `metadata`. +Custom tools are registered as `ToolSpec` objects. A handler may return a `ToolResult`, a string, JSON-serializable data, or an ordered sequence of `TextBlock` and `ImageBlock` values. Text-only results keep the JSON envelope with `ok`, `content`, and `metadata`; image-bearing results use provider-native blocks while keeping `ok` and metadata visible to the model. ```python from pydantic import BaseModel @@ -249,23 +298,7 @@ The paused result includes: Resume with `resume_approvals(...)`, `stream_approvals(...)`, or `resume_approvals_sync(...)` and one `ApprovalDecision` per pending approval. Approved calls execute through the normal tool machinery, including hooks, tracing, retry accounting, and stream events. Rejected calls do not execute or fire tool hooks; the model receives a failed tool result with `error_type="ApprovalRejected"` and can explain, recover, or request another tool. -Approval-required tools need a resumable model because the harness must continue after the paused assistant tool-call turn. They are not supported inside child subagent harnesses. Built-in tools remain non-approval tools in this version; wrap built-in behavior in a custom `ToolSpec` when host review is required. - -### Bash Prototype Tool - -`BashTool` is an opt-in custom tool for exploratory agent runs. It is not part of the default built-ins, and `builtin_tools=["bash"]` is intentionally rejected. - -```python -from thinharness import BashTool, Harness, HarnessConfig - - -harness = Harness( - HarnessConfig(root="."), - tools=[BashTool(root=".").spec()], -) -``` - -The tool runs one `bash -c` command from a workspace-contained cwd and marks itself sequential because commands may mutate state. The cwd check is not a sandbox: commands can still access absolute paths, network tools, environment variables, and anything the host process can access. The tool has a configured `max_tool_chars` output cap; the model can pass `max_chars` on an individual call only to request a lower cap. The final limit is `min(max_chars, max_tool_chars)`, applied independently to stdout and stderr. Background descendants left by a command are cleaned up when the shell exits; this is not a persistent job runner. Use it to prototype workflow shape, then promote repeated shell logic into typed tools. +Approval-required tools need a resumable model because the harness must continue after the paused assistant tool-call turn. They are not supported inside child harnesses. Configure approval only at the top level, either on a direct `ToolSpec` or with `BashPlugin(requires_approval=True)`. ## Tool Execution Policy @@ -296,7 +329,7 @@ Set `output_type` to validate the final result with Pydantic. `result.text` rema ```python from pydantic import BaseModel -from thinharness import Harness, HarnessConfig +from thinharness import FilesystemPlugin, Harness, HarnessConfig class Summary(BaseModel): @@ -304,11 +337,14 @@ class Summary(BaseModel): bullets: list[str] -harness = Harness(HarnessConfig( - root=".", - output_type=Summary, - output_mode="auto", -)) +harness = Harness( + HarnessConfig( + root=".", + output_type=Summary, + output_mode="auto", + ), + plugins=[FilesystemPlugin(tools=["read"])], +) result = await harness.run("Summarize README.md.") summary: Summary = result.output @@ -326,6 +362,12 @@ ThinHarness resolves structured output when the harness is constructed and eager Tool-mode structured output uses a harness-created `final_result` tool. It is not a normal registered tool, does not fire tool hooks, and clean exits through it are not resumable because the provider transcript would contain an unanswered synthetic tool call. +## Image Content + +`Harness.run()`, `stream()`, and `run_sync()` accept a non-empty string or an ordered sequence of `TextBlock` and `ImageBlock` values. Images use immutable bytes with an explicit `image/jpeg`, `image/png`, `image/gif`, or `image/webp` media type. Empty content, unsupported media types, URLs, paths, and mutable byte containers are rejected before provider work. Provider or model errors report unsupported vision models. + +OpenAI, Anthropic, and OpenRouter receive provider-native image parts. OpenRouter projects tool-result images into a labelled follow-up user message because multimodal tool-role support is inconsistent. Bash, subagent tasks, and `parallel_llm` prompts remain text-only. Completed results and resume state can contain full base64 image data, so treat them as sensitive and potentially large. + ## Hooks Hooks are runtime callables registered on a `Harness`. They can observe lifecycle events, append prompt context, cancel selected before-events, or rewrite tool output. @@ -355,62 +397,67 @@ Hook events: - `limit_reached` - `run_end` -`user_prompt_submit`, `before_tool_call`, and `before_subagent_run` are cancellable. `after_tool_call` can rewrite `ctx.output`, but retry control flow is captured before that rewrite. Tool filters apply only to tool events; agent filters apply only to subagent events. +`user_prompt_submit`, `before_tool_call`, and `before_subagent_run` are cancellable. Run-start and prompt-submit hooks receive normalized content-block tuples and can replace the prompt with a string or valid block sequence. Invalid prompt replacements fail as `HarnessError`. `after_tool_call` can rewrite canonical `ctx.output` or structured `ctx.envelope`; either form is validated and keeps the other synchronized. A malformed non-strict mutation is logged and rolled back, while a malformed strict mutation fails as `HarnessError`. Tool retry control flow and budgets use the result classification from before after-tool hooks; hooks can change model-visible output but cannot create or suppress the current retry. These after-tool fields can contain full base64 image data and can be sensitive and large. Tool filters apply only to tool events; agent filters apply only to subagent events. By default, hook exceptions are logged and the run continues. Set `strict_hooks=True` to make hook exceptions fail the run. ## Subagents -The `subagent` tool is opt-in. It lets the parent delegate a bounded task to a child harness. Child runs start fresh; they do not inherit the parent provider transcript. +Add `SubagentsPlugin` to expose one `subagent` tool. Each call delegates one bounded task to a fresh child harness with no parent provider transcript. ```python -from thinharness import Harness, HarnessConfig, SubAgentConfig +from thinharness import FilesystemPlugin, Harness, HarnessConfig, SubAgentConfig, SubagentsPlugin -harness = Harness(HarnessConfig( - root=".", - builtin_tools=["read", "search", "subagent"], - subagents=[ - SubAgentConfig( - name="reviewer", - description="Review a draft for factual and citation issues.", - system_prompt="You are a careful review agent.", - inherit_parent_tools=True, - max_model_requests=12, - ) +harness = Harness( + HarnessConfig(root="."), + plugins=[ + FilesystemPlugin(tools=["read", "search"]), + SubagentsPlugin(agents=[ + SubAgentConfig( + name="reviewer", + description="Review a draft for factual and citation issues.", + system_prompt="You are a careful review agent.", + inherit_parent=True, + max_model_requests=12, + ) + ]), ], -)) +) ``` -Calling `subagent` without an `agent` argument uses the framework default subagent, which inherits parent tools except for recursive `subagent` access and MCP-discovered tools. Named subagents use their own `SubAgentConfig`. +Omitting `agent` selects the framework default child. It borrows the parent model and inherits safe parent plugins plus direct tools from the active run's frozen tool snapshot. Named children can set their own model, limits, output settings, hooks, plugins, and tools. `inherit_parent=True` is additive: inherited plugins and direct tools come first, then explicit child plugins and tools. Duplicate names fail; explicit values do not replace inherited values. -Named subagents can: +`FilesystemPlugin`, `SkillsPlugin`, and `ParallelLlmPlugin` opt into child rebinding. A borrowed `ParallelLlmPlugin(model=None)` uses the child model. `MCPPlugin` never inherits automatically; put an explicit `MCPPlugin(...)` in the child's `plugins` list. Approval-required tools are excluded or rejected. -- inherit parent tools with `inherit_parent_tools=True` -- choose explicit `builtin_tools` -- receive explicit custom `tools` -- opt into MCP with `inherit_mcp_servers=True` or `mcp_servers=[...]` -- use their own model, limits, and structured output +Default-child hooks use `SubagentsPlugin(default_hooks=...)`. Named child hooks use `SubAgentConfig(hooks=...)`. Parent `before_subagent_run` and `after_subagent_run` hooks stay on the parent and can use `agents=[...]` filters. -`default` is reserved for the framework default subagent name. +Children receive a disabled child host and cannot delegate again. `SubagentsPlugin` is also invalid in explicit child plugins. A custom ordinary tool can still use the name `subagent` when the delegation plugin is absent. `default` is reserved only as the framework default child configuration name. ## Parallel LLM Batches -`parallel_llm` is an opt-in built-in tool for batches of independent one-shot prompts: +Add `ParallelLlmPlugin` for batches of independent one-shot text prompts: ```python -harness = Harness(HarnessConfig( - root=".", - builtin_tools=["parallel_llm"], - builtin_parallel_llm_model="openai:gpt-5.5-mini", - builtin_parallel_llm_temperature=0, - parallel_llm_max_prompts=100, - request_retries=3, - request_retry_backoff=1.0, -)) +from thinharness import Harness, HarnessConfig, ParallelLlmPlugin + +harness = Harness( + HarnessConfig(root="."), + plugins=[ParallelLlmPlugin( + model="openai:gpt-5.5-mini", + temperature=0, + max_prompts=100, + request_retries=3, + request_retry_backoff=1.0, + read_paths=["inputs"], + write_paths=["outputs"], + )], +) ``` -Each batch call is stateless. Per-prompt calls receive no tools, no memory, no continuation, and no inherited parent harness system prompt. Pass `system` when the batch needs shared instructions. +Omit `model` to borrow the configured harness model, or pass a model object to borrow a caller-owned model. The plugin never closes a borrowed model. Provider and request settings are accepted only with a model string; that form creates and closes a configured provider for each batch invocation. Alternate string models do not inherit hidden harness provider settings. + +Each batch call is stateless and text-only. Per-prompt calls receive no tools, no memory, no continuation, and no inherited parent harness system prompt. Pass `system` when the batch needs shared instructions. The model-facing prompt source is structurally discriminated: @@ -419,7 +466,7 @@ The model-facing prompt source is structurally discriminated: Use `output_file` when combined results may be large. Inline output returns compact JSON in `ToolResult.content`; file output writes pretty JSON under the write path policy and returns a summary. -`max_concurrency` is model-controlled per tool call and limits in-flight requests. `parallel_llm_max_prompts` is a host-controlled `HarnessConfig` field. Built-in provider retries use the same `request_retries` and `request_retry_backoff` policy as normal agent requests. Transport attempts do not increase the tool's `model_requests` count or consume `max_model_requests`; the `parallel_llm` invocation still counts as one tool call. +`max_concurrency` is model-controlled per tool call and limits in-flight requests. `max_prompts` is host-controlled on `ParallelLlmPlugin`. Read and write paths resolve under `HarnessConfig.root`. A plugin that borrows the harness model uses that model's transport retry settings; a string-model plugin uses its own. Transport attempts do not increase the tool's batch-local `model_requests` count. Batch requests and tokens do not enter parent `RunUsage` or consume parent `max_model_requests`; the `parallel_llm` invocation counts as one parent tool call. Cancellation propagates and still closes a string-model provider. For a custom, renameable version, construct `ParallelLlmTool` directly: @@ -456,20 +503,29 @@ When `output_type` is set on a custom `ParallelLlmTool`, successful entries cont ## Skills -Skills are explicit tools, not auto-discovery. Configure `skills_dir`, then expose `skill_read` and/or `skill_run` through `builtin_tools`. +Skills are explicit plugins, not auto-discovery. `SkillsPlugin` requires an ordered, non-empty tool selection, so discovery never silently enables script execution. ```python -harness = Harness(HarnessConfig( - root=".", - skills_dir="skills", - selected_skills=["invoice-review"], - builtin_tools=["read", "search", "skill_read", "skill_run"], -)) +from thinharness import FilesystemPlugin, Harness, HarnessConfig, SkillsPlugin + +harness = Harness( + HarnessConfig(root="."), + plugins=[ + FilesystemPlugin(tools=["read", "search"]), + SkillsPlugin( + "skills", + selected_skills=["invoice-review"], + tools=["skill_read", "skill_run"], + ), + ], +) ``` -If skills are configured and skill tools are exposed, the system prompt includes a compact skill summary. The model still has to call `skill_read` to inspect details. +Relative skill directories resolve from the process working directory, not from `HarnessConfig.root`. The plugin discovers one catalog when it is constructed. Names, paths, selected skills, and summary text stay fixed; added or removed skills require a new plugin. Existing `SKILL.md` content, file trees, and scripts stay live when tools run. Reusing one plugin object across harnesses reuses the same catalog and registry. -`skill_run` runs scripts from trusted skill directories. Python scripts run through `uv run`; shell scripts run through `bash`; JavaScript and Go files use `node` and `go run`. +A non-empty catalog contributes the selected tools in caller order and one compact summary. The summary tells the model to call `skill_read` only when that tool is selected. `skill_read` is parallel-safe. `skill_run` is sequential and runs scripts from trusted skill directories: Python through `uv run`, shell through `bash`, JavaScript through `node`, and Go through `go run`. + +`SkillsPlugin` has frozen constructor configuration and opts into generic child rebinding. Default children and named children with `inherit_parent=True` reuse the exact registry and catalog, so they share one catalog and one summary. A non-inheriting child lists its own plugin explicitly. ## MCP @@ -479,19 +535,21 @@ MCP support is optional. Importing ThinHarness does not require the MCP packages ```python from fastmcp.client.transports import FastMCPTransport -from thinharness import Harness, HarnessConfig, MCPServer +from thinharness import Harness, HarnessConfig, MCPPlugin, MCPServer -harness = Harness(HarnessConfig( - root=".", - mcp_servers=[ - MCPServer( - FastMCPTransport(my_server), - id="inprocess", - include_tools=["step", "reset_session"], - ) +harness = Harness( + HarnessConfig(root="."), + plugins=[ + MCPPlugin(servers=[ + MCPServer( + FastMCPTransport(my_server), + id="inprocess", + include_tools=["step", "reset_session"], + ) + ]) ], -)) +) ``` `MCPServer` accepts only a transport object — not a URL, script path, server object, or configuration dictionary. Once a transport is passed to an `MCPServer`, that wrapper owns the client built on it and closes its transport on the final exit; to share one session, reuse the wrapper rather than passing one stateful transport to several wrappers. @@ -499,23 +557,25 @@ harness = Harness(HarnessConfig( The stdio, SSE, and Streamable HTTP wrappers take command- or URL-based constructors and build the matching FastMCP transport when the connection opens: ```python -from thinharness import Harness, HarnessConfig, MCPServerStdio +from thinharness import Harness, HarnessConfig, MCPPlugin, MCPServerStdio -harness = Harness(HarnessConfig( - root=".", - mcp_servers=[ - MCPServerStdio( - "uvx", - ["my-mcp-server"], - tool_prefix="external", - include_tools=["lookup"], - ) +harness = Harness( + HarnessConfig(root="."), + plugins=[ + MCPPlugin(servers=[ + MCPServerStdio( + "uvx", + ["my-mcp-server"], + tool_prefix="external", + include_tools=["lookup"], + ) + ]) ], -)) +) ``` -MCP servers connect lazily during harness startup. Discovered MCP tools become normal `ToolSpec` objects in the live harness tool map. Name collisions are rejected; use `tool_prefix`, `include_tools`, or `exclude_tools` to keep the model-facing tool surface explicit. +Use one `MCPPlugin` per harness and put all servers in caller order. Servers connect lazily on `Harness.connect()` or the first run. The binding discovers one tool snapshot and reuses it until the harness closes. Discovered MCP tools become normal `ToolSpec` objects with generic origin data in the live harness tool map. Name collisions reject the whole discovered contribution; use `tool_prefix`, `include_tools`, or `exclude_tools` to keep the model-facing tool surface explicit. Available wrappers: @@ -524,7 +584,7 @@ Available wrappers: - `MCPServerSSE` - `MCPServerStreamableHTTP` -ThinHarness only turns MCP tools into harness tools; transport execution and session lifecycle come from the FastMCP client. MCP prompts, resources, sampling, OAuth flows, provider-native MCP, and `.mcp.json` discovery are outside the current scope. +ThinHarness only turns MCP tools into harness tools; transport execution and session lifecycle come from the FastMCP client. Successful supported MCP images remain ordered image blocks. When `structuredContent` exists, its canonical JSON is the authoritative first text block; text, audio, resource, and resource-link blocks are discarded; and only image blocks or image placeholders keep their relative order. MCP never inherits automatically into a child. A child that needs MCP lists an explicit `MCPPlugin` in `SubAgentConfig.plugins`, and that child binding owns its connection lifecycle. MCP prompts, resources, sampling, OAuth flows, provider-native MCP, and `.mcp.json` discovery are outside the current scope. ## Resume @@ -558,8 +618,8 @@ Budgets span the pause. The paused batch counts against `usage.tool_calls` exact Built-in provider resume details: -- `resume_state["kind"] == "transcript"` and `version == 3`. -- The transcript is provider-agnostic and no longer depends on OpenAI server-side response retention. +- `resume_state["kind"] == "transcript"` and `version == 4`. Older transcript versions must be regenerated; approval envelopes with version 3 nested provider state also fail. +- The transcript is provider-agnostic and no longer depends on OpenAI server-side response retention. Ordered image bytes are self-contained as base64, which adds about 33% encoding overhead. Exact structured-output retry wire text is also stored and replayed byte-for-byte when it differs from the canonical tool result. - Provider-specific reasoning chains are preserved on same-provider resume (Anthropic thinking signatures, OpenAI `encrypted_content`, OpenRouter `reasoning_details`) and degraded to a leading ``-tagged text block on cross-provider resume. Anthropic native re-emit also requires extended thinking to be enabled in the resuming run. For reasoning-capable OpenAI models the harness adds `include=["reasoning.encrypted_content"]`, so `resume_state` can contain encrypted reasoning blobs — treat it as sensitive. - Cross-provider resume is supported by the built-in renderers, but real providers may reject foreign-format tool-call ids or malformed tool-call argument JSON. - `OpenAIResponsesSession.start(prompt, constants, previous_response_id=...)` remains available as a low-level escape hatch, but later resume state captures only the new prompt onward, not the externally seeded prior turns. @@ -599,7 +659,7 @@ Local tracing is on by default. It writes plaintext JSONL traces under: ~/.thinharness/traces// ``` -Those traces can include prompts, model outputs, tool arguments, and tool results. Treat them as sensitive local data. +Those traces can include prompt text, model outputs, tool arguments, and tool-result text. The agent span shows the normalized raw caller prompt, while the first model span shows the effective prompt after hooks; approval resume adds no prompt. Image bytes, base64, and data URLs are replaced with ordered descriptors that contain media type, byte size, and block index. Provider failures keep their `ProviderError` classification and status code after message redaction. Completed results and resume state still retain full images; treat them as sensitive local data. Disable local trace files with: @@ -636,7 +696,7 @@ harness = Harness( ) ``` -Each tracing sink owns its capture policy. External spans can exist without recording raw prompts or tool payloads unless capture flags are enabled. +Each tracing sink owns its capture policy. External spans can exist without recording raw prompts or tool payloads unless capture flags are enabled. Real delegation tool spans have `subagent.delegation=true` from span start and child `invoke_agent subagent.` spans remain nested below them. Classification uses core-owned composition provenance, so a same-named direct tool or forged `ToolOrigin` remains an ordinary tool span. ## Result Object @@ -645,7 +705,7 @@ Each tracing sink owns its capture policy. External spans can exist without reco - `text`: final model text. - `output`: parsed structured output, if configured. - `responses`: raw provider responses. -- `tool_call_records`: normalized tool call and output records. +- `tool_call_records`: normalized tool call records with canonical structured `result` and string `output`; image-bearing `output` is redacted while `result` retains complete image data. - `usage`: model request counts, tool call counts, cancellations, retry counters, and run token totals (`input_tokens`/`output_tokens`). - `stop_reason`: terminal reason. - `resume_state`: opaque continuation state when the run is cleanly resumable. diff --git a/docs/site/about/index.html b/docs/site/about/index.html deleted file mode 100644 index 17a9e95..0000000 --- a/docs/site/about/index.html +++ /dev/null @@ -1,228 +0,0 @@ - - - - - -About — ThinHarness - - - - - - - -
-
-
- ThinHarness -

A minimal, opinionated agent harness — focused scope, straightforward code, easy to fork.

- -
- - - -
-
// why this exists
-

Why this exists

-

ThinHarness is for building agents with a defined job and a bounded set of tools, using a refreshingly simple framework that's easy to inspect and customize.

Production agents rarely stop at framework configuration. Things like orchestration, permissions, user/session storage, and deployment become specific to the application and its users.

ThinHarness exists for the gap between building the agent loop yourself and adopting a large agent runtime where the loop comes bundled with assumptions you don’t need and can’t easily change.

It owns a focused set of agent-loop primitives that generalize well and are tedious to rebuild, leaving the rest of the application stack for you to own.

I started building ThinHarness after running into this gap in practice. Filesystem-enabled agents are simple yet powerful, but you mostly get them by adopting a large framework with layers of abstraction. I usually needed only a small slice of the functionality, but that slice came with coupled assumptions that didn't match my application. Making it fit meant writing enough wrappers, adapters, and fixes that I ended up owning framework-shaped code anyway.

-
- -
-
// loc comparison
-

How small, exactly

-

Framework-only LOC. Each row strips non-framework code (platform/deployment, voice/realtime, eval suites, UI/CLI, wire protocols) from the upstream package. Provider implementations stay in.

-
- yes - partial - no -
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
LibraryLOC1Tool
retries2
Sub-
agents
SkillsFS
tools
OTel
tracing
ThinHarness
8,035
Claude Agent SDK
8,2633
smolagents
9,840
deepagents
17,6644
AWS Strands
32,526
Microsoft Agent Framework
41,331
Pydantic AI
59,087
Google ADK
65,799
OpenAI Agents SDK
73,796
Agno
113,477
-
-

Table focuses on harness-level features that differentiate the libraries. All listed also support MCP, lifecycle hooks, multi-turn conversations, structured output, and human-in-the-loop. It intentionally does not compare framework/platform features like vector DB integrations, hosted deployment, memory/session stores, or broad SaaS connectors.

-
-

1. LOC excludes anything that is not the core agent harness framework. See raw README source comments for exact commands.

-

2. Tool retries: a documented primitive (e.g. Pydantic AI's ModelRetry) that lets tools signal "model passed bad args — retry with this feedback," distinct from generic exception propagation.

-

3. Claude Agent SDK shells out to the Claude Code CLI binary, which is 200k+ LOC.

-

4. deepagents is a thin wrapper over LangChain/LangGraph; effective import surface is ≈112k LOC.

-
-

See docs/table.md for per-cell rationale and how the LOC numbers are measured.

-
- -
-
// opinions
-

Opinions

-

ThinHarness has opinions. They are the reason it stays small.

-
-
purpose_built

Purpose-built agents, not universal agents

ThinHarness is for bounded agent loops, not open-ended interactive assistants like Claude Code or OpenClaw. For business use cases, focused agent loops orchestrated by deterministic code are usually a better fit than sprawling multi-agent systems with broad authority.

-
no_bash

No bash by default

Purpose-built business agents usually don't need a shell. Bash is a broad security and reliability surface: it gives the model open-ended authority instead of typed, bounded actions. ThinHarness keeps bash out of the default and built-in tool sets, but exposes an opt-in BashTool for exploratory runs before the workflow is hardened with typed tools.

-
search

Search is a top priority

The search tool exposes ripgrep as compact grouped path/line results, tuned for document and business-workflow agents rather than code navigation. There's also a jsonl_search variant, because JSONL is the right shape when you're replacing RAG with agent-driven search over structured data: ripgrep row prefiltering, jq-style field projection, where filters, range filters, and snippets from large multiline fields.

-
parallel_llm

Parallel LLM calls, built in

Fan out from inside the harness when a workflow needs efficient parallel processing or majority vote for reliability. Set builtin_parallel_llm_model to enable the default parallel_llm tool for plain-text batches; for validated structured output per call, instantiate ParallelLlmTool yourself with output_type (a Pydantic model). Each call is stateless, and large batches can write JSON to output_file.

-
no_token_streaming

No token streaming

Streaming is for workflow progress, not live chatbot text. ThinHarness emits run, model-turn, tool, retry, limit, and subagent events, but it does not stream provider token deltas. Token streaming would add provider-specific plumbing, event merging, cancellation edge cases, and more surface area to keep stable. For workflow-style agents, step-level updates are usually the useful signal.

-
providers

Three providers, no matrix

ThinHarness ships small provider classes for OpenAI, Anthropic, and OpenRouter. If your gateway speaks one of those protocols, you swap a base URL and move on. If not, the provider classes are small enough to fork or replace, and ignoring the bundled ones costs you nothing.

-
no_compaction

No compaction

Compaction is a workaround for context windows filling up across long, accumulating runs — useful for interactive coding sessions that sprawl over hours. For SDK-based business agents, the right answer to "context is getting big" is almost always better task decomposition: shorter runs, separate harness instances, narrower subagents.

-
no_deployment

No deployment layer

Agents still need serving, auth, durable jobs, user/session storage, and deployment in production. ThinHarness does not try to own that stack. A bundled deployment layer might work for some teams, but it will miss plenty of real production shapes; instead of adding more code and more options, ThinHarness leaves that application stack for you to own.

-
-
- -
-
// install
-

Install

-
$ uv add thinharness  # or pip install thinharness
-

Requires Python 3.11+.

-
- -
-
// use
-

Use

-
import asyncio
-from thinharness import Harness, HarnessConfig
-
-async def main():
-    async with Harness(HarnessConfig(root=".", model="openai:gpt-5.5")) as harness:
-        result = await harness.run("Read README.md and summarize it.")
-        print(result.text)
-
-asyncio.run(main())
-

There's a synchronous wrapper too: Harness(...).run_sync(...).

-
- -
-
// features
-

Features

-
-
Filesystem tools

read, write, batched exact-replacement edit, search, list, and glob with root-scoped path policies.

-
JSONL search

Opt-in jsonl_search for structured line-delimited data, with ripgrep prefiltering, field projection, equality/contains/regex/range where filters, and field-level snippets from large multiline string values.

-
Bash prototype tool

Opt-in BashTool for exploratory shell commands. It is lightweight, custom-registration only, and is not included in the default or built-in tool set.

-
Provider adapters

Built-in OpenAI, Anthropic, and OpenRouter adapters, plus public model/session protocols for implementing another provider.

-
Custom typed tools

Define sync or async ToolSpec handlers with Pydantic argument models, normalized ToolResult envelopes, sequential/approval flags, and per-tool retry settings.

-
Structured output

Pydantic-validated results with native, tool, prompted, and text modes.

-
Hooks

Lifecycle and tool-call interception for prompt submission, tool calls, subagents, limits, and run boundaries.

-
Subagents

Opt-in delegation through a built-in subagent tool and explicit SubAgentConfig.

-
Parallel LLM

Opt-in parallel_llm fan-out for batches of independent one-shot prompts, plus ParallelLlmTool(...).spec() for renameable tools with explicit model, path, prompt, and provider request settings.

-
Skills

Explicit skill_read and skill_run tools for selected skill directories, with Python, shell, JavaScript, and Go script runners.

-
Resume

Clean new-turn continuation through self-contained transcript state that can replay across built-in providers and models, preserving native reasoning on same-provider resume and degrading it to text across providers.

-
MCP

Optional MCP support built on the FastMCP client, including in-process servers via FastMCPTransport, with lazy tool discovery and collision checks.

-
Parallel tool calls

Same-turn tool batches run concurrently when every called tool is parallel-safe.

-
Human approvals

Mark custom tools as approval-required so a run pauses before side effects, returns pending call details plus resume state, then continues after an approve/reject decision.

-
Event streaming

Async coarse-grained run, model, tool, retry, limit, and subagent events for workflow visibility.

-
Tool retries

Tools raise ModelRetry to send structured feedback back to the model and trigger a retry within a per-tool budget.

-
Limits and notices

Configured request, tool-call, output-retry, and tool-retry budgets bound each run; near-limit guidance can warn the model before request or tool-call budgets are exhausted.

-
Tracing

Local plaintext JSONL traces plus OpenTelemetry-compatible spans for runs, provider calls, tools, and subagents.

-
-
- -
-
// status
-

Status

-

Pre-1.0. APIs may shift, but I don't expect dramatic changes. Forking is a real option, not just a theoretical one: the codebase is small enough that pulling upstream changes into your fork by hand stays cheap. Each major feature (MCP, subagents, jsonl_search, parallel_llm, skills) lives in its own file with no hidden dependencies. If you don't use one, that's even less code to worry about. If you want to delete it entirely, that's a one-shot 10-word prompt to a coding agent.

-
- -
-
// license
-

License

-

MIT. See LICENSE.

-
-
-
- -
- -
- - diff --git a/docs/site/assets/ThinHarness.svg b/docs/site/assets/ThinHarness.svg deleted file mode 100644 index 3e1608d..0000000 --- a/docs/site/assets/ThinHarness.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/docs/site/assets/agno-a.svg b/docs/site/assets/agno-a.svg deleted file mode 100644 index 0e07b94..0000000 --- a/docs/site/assets/agno-a.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/docs/site/assets/apple-touch-icon.png b/docs/site/assets/apple-touch-icon.png deleted file mode 100644 index 5cab3e7..0000000 Binary files a/docs/site/assets/apple-touch-icon.png and /dev/null differ diff --git a/docs/site/assets/favicon.svg b/docs/site/assets/favicon.svg deleted file mode 100644 index e28faca..0000000 --- a/docs/site/assets/favicon.svg +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - - diff --git a/docs/site/assets/github-mark.svg b/docs/site/assets/github-mark.svg deleted file mode 100644 index a605d37..0000000 --- a/docs/site/assets/github-mark.svg +++ /dev/null @@ -1,3 +0,0 @@ - diff --git a/docs/site/assets/site.css b/docs/site/assets/site.css deleted file mode 100644 index c62ed3d..0000000 --- a/docs/site/assets/site.css +++ /dev/null @@ -1,1838 +0,0 @@ -/* ============================================================ - ThinHarness — shared site theme (Direction B · "Terminal") - Cool blue-grey · monospace-forward · forest-green accent - ============================================================ */ - -@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&family=Inter+Tight:wght@400;500;600;700&display=swap'); - -:root{ - --bg:#eceff2; - --panel:#ffffff; - --panel-2:#f5f7f9; - --ink:#19222b; - --ink-soft:#3a4650; - --muted:#69757f; - --line:#d5dde3; - --line-2:#e3e9ed; - --green:#1f7a4d; - --green-bright:#16924f; - --green-deep:#185f3c; - --green-wash:#e4f0e9; - --green-line:#c4ddcd; - --term-bg:#16201b; - --term-ink:#d6e6d8; - --amber:#946a14; - --amber-wash:#f6efda; - --red:#9d3a35; - --red-wash:#f6e6e4; - --radius:10px; - --sans:"Inter Tight",system-ui,-apple-system,sans-serif; - --mono:"JetBrains Mono",ui-monospace,SFMono-Regular,Menlo,monospace; -} - -/* ---- grid background, applied to ---- */ -.th{ - margin:0; - background:var(--bg); - color:var(--ink); - font-family:var(--sans); - line-height:1.55; - -webkit-font-smoothing:antialiased; - background-image: - linear-gradient(var(--line-2) 1px,transparent 1px), - linear-gradient(90deg,var(--line-2) 1px,transparent 1px); - background-size:64px 64px; - background-position:center top; -} -.th *{box-sizing:border-box;} - -/* ============================================================ - Shared header / nav - ============================================================ */ -.site-header{ - position:sticky;top:0;z-index:50; - background:rgba(236,239,242,.86); - backdrop-filter:blur(8px); - -webkit-backdrop-filter:blur(8px); - border-bottom:1px solid var(--line); -} -.site-header__inner{ - max-width:1180px;margin:0 auto;padding:13px 28px; - display:flex;align-items:center;justify-content:space-between;gap:24px; -} -.site-logo{display:inline-flex;align-items:center;line-height:0;} -.site-logo img{height:28px;display:block;} -.site-nav{display:flex;align-items:center;gap:4px;flex-wrap:wrap;} -.site-nav a{ - font-family:var(--mono);font-size:13px;color:var(--ink-soft); - text-decoration:none;padding:6px 11px;border-radius:7px; - border:1px solid transparent;white-space:nowrap;transition:all .14s ease; -} -.site-nav a:hover{color:var(--green-deep);border-color:var(--line);background:var(--panel);} -.site-nav a.is-active{color:var(--green-deep);background:var(--green-wash);border-color:var(--green-line);} -.site-nav a.ext{color:var(--muted);} -.site-nav a.ext:hover{color:var(--green-deep);} - -/* ============================================================ - Shared footer - ============================================================ */ -.site-footer{ - border-top:1px solid var(--line); - margin-top:8px; -} -.site-footer__inner{ - max-width:1180px;margin:0 auto;padding:26px 28px 56px; - display:flex;align-items:center;justify-content:space-between;gap:20px;flex-wrap:wrap; - font-family:var(--mono);font-size:12.5px;color:var(--muted); -} -.site-footer__inner a{color:var(--muted);text-decoration:none;} -.site-footer__inner a:hover{color:var(--green-deep);} -.site-footer .links{display:flex;gap:18px;flex-wrap:wrap;} -.site-footer .mono-mark{display:flex;align-items:center;gap:10px;} -.site-footer .mono-mark .dot{width:7px;height:7px;border-radius:50%;background:var(--green-bright);} - -@media(max-width:640px){ - .site-header__inner{flex-direction:column;align-items:flex-start;gap:12px;} - .site-nav{gap:2px;} - .site-nav a{padding:5px 8px;font-size:12px;} -} - -/* ============================================================ - Home page - ============================================================ */ - -body.page-home { -font-size:16px; -} - -body.page-home .wrap a { -color:var(--green);text-decoration:none; -} - -body.page-home .wrap { -max-width:1180px;margin:0 auto;padding:0 28px; -} - -/* hero */ - -body.page-home .hero { -padding:72px 0 44px;display:grid;grid-template-columns:1.12fr .88fr;gap:48px;align-items:center; -} - -body.page-home .badge { -display:inline-flex;align-items:center;gap:8px;font-family:var(--mono);font-size:12px; - color:var(--green-deep);background:var(--green-wash);border:1px solid var(--green-line); - border-radius:999px;padding:4px 12px;margin-bottom:22px;white-space:nowrap; -} - -body.page-home .badge .dot { -width:7px;height:7px;border-radius:50%;background:var(--green-bright); -} - -body.page-home .badge a { -color:var(--green-deep);text-decoration:none; -} - -body.page-home .badge a:hover { -text-decoration:underline; -} - -body.page-home .badge .badge-icon-link { -display:inline-flex;align-items:center;justify-content:center;width:16px;height:16px;vertical-align:-3px; -} - -body.page-home .badge .badge-icon-link:hover { -text-decoration:none; -} - -body.page-home .badge .badge-icon-link img { -width:16px;height:16px;display:block;opacity:.86; -} - -body.page-home .badge .badge-icon-link:hover img { -opacity:1; -} - -body.page-home h1.tagline { -font-size:46px;line-height:1.08;letter-spacing:-0.028em;font-weight:700;margin:0 0 20px; -} - -body.page-home .tagline .hl { -color:var(--green); -} - -body.page-home .lede { -color:var(--ink-soft);font-size:18px;max-width:480px;margin:0 0 26px; -} - -body.page-home .hero-cta { -display:flex;gap:10px 20px;align-items:center;flex-wrap:wrap;font-family:var(--mono);font-size:13.5px; -} - -body.page-home .hero-cta a { -color:var(--green-deep);white-space:nowrap; -} - -body.page-home .hero-cta .arrow { -transition:transform .15s;display:inline-block; -} - -body.page-home .hero-cta a:hover .arrow { -transform:translateX(3px); -} - -/* terminal block */ - -body.page-home .term { -background:var(--term-bg);border-radius:12px;overflow:hidden; - box-shadow:0 24px 50px rgba(20,30,24,.20);border:1px solid #0e1612; -} - -body.page-home .term .bar { -display:flex;align-items:center;gap:7px;padding:12px 15px;background:#1c2a22;border-bottom:1px solid #0e1612; -} - -body.page-home .term .bar i { -width:11px;height:11px;border-radius:50%;display:block;background:#33473b; -} - -body.page-home .term .bar .t { -margin-left:8px;font-family:var(--mono);font-size:11.5px;color:#7f9a8a; -} - -body.page-home .term .body { -padding:20px;font-family:var(--mono);font-size:14px;line-height:1.95;color:var(--term-ink); -} - -body.page-home .term .body .c { -color:#5f8c72; -} - -body.page-home .term .body .p { -color:#5a7d68; -} - -body.page-home .term .body .g { -color:#7fd39a; -} - -body.page-home .term .body .w { -color:#e9f3eb; -} - -body.page-home .term .body .copy { -float:right;font-size:10.5px;letter-spacing:.07em;text-transform:uppercase; - color:#6f8c7b;cursor:pointer;border:1px solid #2c4034;border-radius:5px;padding:2px 7px; -} - -body.page-home .term .body .copy:hover { -color:#aee7c0;border-color:#3f5b49; -} - -body.page-home .band { -display:grid;grid-template-columns:repeat(3,1fr);background:var(--panel); - border:1px solid var(--line);border-radius:11px;overflow:hidden;margin-top:14px; -} - -body.page-home .band .cell { -padding:16px 20px;border-right:1px solid var(--line-2); -} - -body.page-home .band .cell:last-child { -border-right:0; -} - -body.page-home .band .cell .k { -font-family:var(--mono);font-size:11px;text-transform:uppercase;letter-spacing:.07em;color:var(--muted); -} - -body.page-home .band .cell .v { -font-family:var(--mono);font-size:25px;font-weight:600;margin-top:5px;color:var(--ink); -} - -body.page-home .band .cell .v span { -color:var(--green); -} - -/* section heads */ - -body.page-home .sechead { -display:flex;align-items:baseline;gap:14px;margin-bottom:26px; -} - -body.page-home .sechead .num { -font-family:var(--mono);font-size:13px;color:var(--green); -} - -body.page-home .sechead h2 { -font-size:25px;font-weight:700;letter-spacing:-0.02em;margin:0; -} - -body.page-home .sechead .rule { -flex:1;height:1px;background:var(--line); -} - -body.page-home .sechead .meta { -font-family:var(--mono);font-size:12px;color:var(--muted); -} - -/* opinions */ - -body.page-home .opinions { -padding:60px 0 18px; -} - -body.page-home .oplist { -display:grid;grid-template-columns:1fr 1fr;gap:1px;background:var(--line); - border:1px solid var(--line);border-radius:12px;overflow:hidden; -} - -body.page-home .op { -background:var(--panel);padding:22px 24px;transition:background .14s; -} - -body.page-home .op:hover { -background:var(--panel-2); -} - -body.page-home .op .tag { -font-family:var(--mono);font-size:12px;color:var(--green); -} - -body.page-home .op h3 { -font-size:17px;font-weight:600;margin:9px 0 6px;letter-spacing:-0.01em; -} - -body.page-home .op p { -margin:0;color:var(--ink-soft);font-size:14.5px;line-height:1.5; -} - -/* explore */ - -body.page-home .explore { -padding:58px 0 30px; -} - -body.page-home .xrow { -display:grid;grid-template-columns:1fr 1fr 1fr;gap:14px; -} - -body.page-home .x { -display:flex;flex-direction:column;background:var(--panel);border:1px solid var(--line); - border-radius:12px;padding:22px;transition:border-color .15s,transform .15s,box-shadow .15s; -} - -body.page-home .x:hover { -border-color:var(--green);transform:translateY(-3px);box-shadow:0 16px 32px rgba(25,34,43,.07); -} - -body.page-home .x .path { -font-family:var(--mono);font-size:12px;color:var(--muted); -} - -body.page-home .x h4 { -font-size:19px;font-weight:600;margin:11px 0 7px;color:var(--ink);letter-spacing:-0.01em; -} - -body.page-home .x p { -margin:0 0 16px;color:var(--muted);font-size:14.5px;flex:1; -} - -body.page-home .x .go { -font-family:var(--mono);font-size:13px;color:var(--green-deep); -} - -@media(max-width:880px) { -body.page-home .hero { -grid-template-columns:1fr;gap:30px; -} - -body.page-home .oplist, body.page-home .xrow { -grid-template-columns:1fr; -} - -body.page-home h1.tagline { -font-size:38px; -} -} - -/* ============================================================ - About page - ============================================================ */ - -body.page-about { -font-size:16px; -} - -body.page-about .wrap a { -color:var(--green);text-decoration:none; -} - -body.page-about .wrap a:hover { -text-decoration:underline; -} - -body.page-about .wrap { -max-width:1080px;margin:0 auto;padding:0 28px; -} - -body.page-about .doc { -max-width:860px;margin:0 auto; -} - -/* hero */ - -body.page-about .doc-hero { -padding:64px 0 30px;text-align:center; -} - -body.page-about .doc-hero .bigmark { -height:46px;margin-bottom:26px; -} - -body.page-about .doc-hero .tag { -font-size:21px;color:var(--ink-soft);max-width:560px;margin:0 auto;line-height:1.5; -} - -body.page-about .doc-hero .tag b { -color:var(--ink);font-weight:600; -} - -body.page-about .badges { -display:flex;justify-content:center;gap:8px;flex-wrap:wrap;margin-top:24px; -} - -body.page-about .badges a { -display:inline-flex;align-items:center;gap:7px;font-family:var(--mono);font-size:12px; - color:var(--ink-soft);background:var(--panel);border:1px solid var(--line);border-radius:7px;padding:5px 11px;text-decoration:none;white-space:nowrap; -} - -body.page-about .badges a:hover { -border-color:var(--green-line);color:var(--green-deep); -} - -body.page-about .badges .b-dot { -width:7px;height:7px;border-radius:50%; -} - -body.page-about .badges .ci .b-dot { -background:var(--green-bright); -} - -body.page-about .badges .lic .b-dot { -background:#3b76c4; -} - -body.page-about .badges .pypi .b-dot { -background:var(--amber); -} - -/* on-page nav */ - -body.page-about .toc { -display:flex;flex-wrap:wrap;gap:6px;justify-content:center;margin:30px 0 8px; - padding:14px 0;border-top:1px solid var(--line);border-bottom:1px solid var(--line); -} - -body.page-about .toc a { -font-family:var(--mono);font-size:12.5px;color:var(--muted);padding:4px 9px;border-radius:6px;text-decoration:none; -} - -body.page-about .toc a:hover { -color:var(--green-deep);background:var(--green-wash); -} - -/* sections */ - -body.page-about section { -padding:46px 0 6px;scroll-margin-top:80px; -} - -body.page-about .eyebrow { -font-family:var(--mono);font-size:12.5px;color:var(--green);letter-spacing:.04em;margin-bottom:8px; -} - -body.page-about h2 { -font-size:28px;font-weight:700;letter-spacing:-0.025em;margin:0 0 18px; -} - -body.page-about h3 { -font-size:18px;font-weight:600;margin:0 0 8px;letter-spacing:-0.01em; -} - -body.page-about p { -margin:0 0 16px;color:var(--ink-soft);font-size:16px;line-height:1.66; -} - -body.page-about .doc strong { -color:var(--ink); -} - -body.page-about code { -font-family:var(--mono);font-size:.88em;background:var(--panel-2);border:1px solid var(--line-2); - border-radius:5px;padding:1px 5px;color:var(--green-deep); -} - -/* terminal / code blocks */ - -body.page-about pre { -margin:0 0 16px;background:var(--term-bg);border-radius:12px;border:1px solid #0e1612; - padding:18px 20px;overflow-x:auto;font-family:var(--mono);font-size:13.5px;line-height:1.75;color:var(--term-ink); - box-shadow:0 16px 36px rgba(20,30,24,.14); -} - -body.page-about pre code { -background:none;border:0;padding:0;color:inherit;font-size:inherit; -} - -body.page-about pre .c { -color:#5f8c72; -} - -body.page-about pre .k { -color:#8fd0a6; -} - -body.page-about pre .s { -color:#cfe6d4; -} - -body.page-about pre .p { -color:#5a7d68; -} - -body.page-about .install-line { -display:flex;align-items:center;gap:14px;background:var(--term-bg);color:#e9f3eb; - border-radius:11px;padding:15px 20px;font-family:var(--mono);font-size:15px;margin-bottom:10px; - border:1px solid #0e1612; -} - -body.page-about .install-line .p { -color:#5a7d68; -} - -body.page-about .install-line .c { -color:#5f8c72; -} - -body.page-about .req { -font-family:var(--mono);font-size:13px;color:var(--muted);margin-bottom:0; -} - -/* comparison table */ - -body.page-about .table-note { -font-family:var(--mono);font-size:12px;color:var(--muted);margin:0 0 14px; -} - -body.page-about .table-wrap { -border:1px solid var(--line);border-radius:12px;overflow:hidden;background:var(--panel); - margin-bottom:14px;overflow-x:auto; -} - -body.page-about table.loc { -width:100%;border-collapse:collapse;font-size:14px;min-width:760px; -} - -body.page-about table.loc th, body.page-about table.loc td { -padding:11px 12px;text-align:center;border-bottom:1px solid var(--line-2); -} - -body.page-about table.loc thead th { -background:var(--panel-2);font-family:var(--mono);font-size:11px;font-weight:600; - text-transform:uppercase;letter-spacing:.04em;color:var(--muted);border-bottom:1px solid var(--line); - vertical-align:bottom; -} - -body.page-about table.loc th.lib, body.page-about table.loc td.lib { -text-align:left; -} - -body.page-about table.loc td.lib { -font-weight:500;color:var(--ink); -} - -body.page-about table.loc td.loc-n { -font-family:var(--mono);font-weight:600;color:var(--ink);text-align:right; -} - -body.page-about table.loc tbody tr:last-child td { -border-bottom:0; -} - -body.page-about table.loc tbody tr:hover { -background:var(--panel-2); -} - -body.page-about table.loc tr.me { -background:var(--green-wash)!important; -} - -body.page-about table.loc tr.me td { -border-bottom-color:var(--green-line); -} - -body.page-about table.loc tr.me td.lib { -color:var(--green-deep);font-weight:700; -} - -body.page-about table.loc tr.me td.loc-n { -color:var(--green-deep); -} - -body.page-about .lib-cell { -display:flex;align-items:center;gap:9px; -} - -body.page-about .lib-cell img { -width:18px;height:18px;opacity:.85;flex:none; -} - -body.page-about .lib-cell img.thinharness-table-logo { -width:auto;height:13px; -} - -body.page-about .lib-cell sup { -color:var(--muted);font-size:10px; -} - -body.page-about .mark { -font-family:var(--mono);font-size:15px;line-height:1; -} - -body.page-about .mark.y { -color:var(--green-bright); -} - -body.page-about .mark.n { -color:#c2ccd3; -} - -body.page-about .mark.p { -color:var(--amber); -} - -body.page-about tr.me .mark.y { -color:var(--green-deep); -} - -body.page-about .legend { -display:flex;gap:18px;flex-wrap:wrap;font-family:var(--mono);font-size:12px;color:var(--muted);margin-bottom:18px; -} - -body.page-about .legend span { -display:inline-flex;align-items:center;gap:6px; -} - -body.page-about .footnotes { -font-size:13px;color:var(--muted);line-height:1.6;border-left:2px solid var(--line);padding-left:16px; -} - -body.page-about .footnotes p { -font-size:13px;color:var(--muted);margin:0 0 8px; -} - -/* opinions */ - -body.page-about .op-list { -display:grid;gap:1px;background:var(--line);border:1px solid var(--line);border-radius:12px;overflow:hidden; -} - -body.page-about .op-item { -background:var(--panel);padding:22px 24px; -} - -body.page-about .op-item .tag { -font-family:var(--mono);font-size:12px;color:var(--green); -} - -body.page-about .op-item h3 { -margin:8px 0 7px; -} - -body.page-about .op-item p { -margin:0;font-size:15px; -} - -/* features */ - -body.page-about .feat { -display:grid;grid-template-columns:1fr 1fr;gap:1px;background:var(--line);border:1px solid var(--line);border-radius:12px;overflow:hidden; -} - -body.page-about .feat .f { -background:var(--panel);padding:18px 20px; -} - -body.page-about .feat .f .ft { -font-family:var(--mono);font-size:13px;color:var(--green-deep);font-weight:600;margin-bottom:5px; -} - -body.page-about .feat .f p { -margin:0;font-size:14px;color:var(--ink-soft);line-height:1.55; -} - -body.page-about .callout { -background:var(--amber-wash);border:1px solid #e7d6a8;border-radius:10px;padding:16px 18px;margin-bottom:16px; -} - -body.page-about .callout p { -margin:0;font-size:14.5px;color:#5e4a18; -} - -body.page-about .source-link { -font-family:var(--mono);font-size:13px;color:var(--muted); -} - -body.page-about .table-note-summary { -margin-bottom:18px; -} - -body.page-about .source-note { -margin-top:16px; -} - -@media(max-width:760px) { -body.page-about .feat { -grid-template-columns:1fr; -} - -body.page-about h2 { -font-size:24px; -} -} - -/* ============================================================ - Explainer page - ============================================================ */ - -:root { ---bg: #eceff2; - --paper: #ffffff; - --ink: #19222b; - --muted: #69757f; - --line: #d5dde3; - --soft: #e3e9ed; - --blue: #1f7a4d; - --green: #185f3c; - --red: #9d3a35; - --gold: #946a14; - --code-bg: #16201b; - --code-ink: #d6e6d8; - --shadow: 0 16px 38px rgba(25, 34, 43, 0.08); - --radius: 10px; - --mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace; - --sans: "Inter Tight", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; -} - -body.page-explainer * { -box-sizing: border-box; -} - -html:has(body.page-explainer) { -scroll-behavior: smooth; -} - -body.page-explainer { -margin: 0; - background: var(--bg); - color: var(--ink); - font-family: var(--sans); - line-height: 1.55; - -webkit-font-smoothing: antialiased; - background-image: - linear-gradient(#e3e9ed 1px, transparent 1px), - linear-gradient(90deg, #e3e9ed 1px, transparent 1px); - background-size: 64px 64px; - background-position: center top; -} - -body.page-explainer .page a { -color: var(--blue); -} - -body.page-explainer .page { -max-width: 1180px; - margin: 0 auto; - padding: 32px 22px 56px; -} - -body.page-explainer .page > header { -padding: 30px 0 24px; - border-bottom: 1px solid var(--line); -} - -body.page-explainer .eyebrow { -color: var(--green); - font-family: var(--mono); - font-size: 13px; - text-transform: none; - letter-spacing: 0.02em; - font-weight: 500; -} - -body.page-explainer .page h1 { -margin: 10px 0 12px; - font-size: 40px; - line-height: 1.06; - letter-spacing: -0.025em; - font-weight: 700; -} - -body.page-explainer .lede { -max-width: 820px; - color: #3a4650; - font-size: 18px; - margin: 0; -} - -body.page-explainer .page > header nav { -display: flex; - flex-wrap: wrap; - gap: 8px; - margin: 22px 0 0; -} - -body.page-explainer .page > header nav a { -display: inline-block; - border: 1px solid var(--line); - background: var(--paper); - color: var(--ink-soft, #3a4650); - text-decoration: none; - padding: 7px 11px; - border-radius: 7px; - font-size: 13px; - font-family: var(--mono); - transition: all .14s ease; -} - -body.page-explainer .page > header nav a:hover { -color: var(--green); - border-color: #c4ddcd; - background: #e4f0e9; -} - -body.page-explainer .page section { -margin: 34px 0; -} - -body.page-explainer .page h2 { -margin: 0 0 14px; - font-size: 25px; - line-height: 1.2; - letter-spacing: 0; -} - -body.page-explainer .page h3 { -margin: 0 0 10px; - font-size: 18px; - letter-spacing: 0; -} - -body.page-explainer .page p { -margin: 0 0 12px; -} - -body.page-explainer .grid { -display: grid; - gap: 14px; -} - -body.page-explainer .grid.cols-2 { -grid-template-columns: repeat(2, minmax(0, 1fr)); -} - -body.page-explainer .grid.cols-3 { -grid-template-columns: repeat(3, minmax(0, 1fr)); -} - -body.page-explainer .grid-spaced { -margin-top: 16px; -} - -body.page-explainer .table-block { -margin: 22px 0 20px; -} - -body.page-explainer .table-block h3 { -margin-bottom: 12px; -} - -body.page-explainer .grid > * { -min-width: 0; -} - -body.page-explainer .panel { -background: var(--paper); - border: 1px solid var(--line); - border-radius: var(--radius); - padding: 16px; - box-shadow: var(--shadow); - min-width: 0; -} - -body.page-explainer .plain-panel { -background: var(--paper); - border: 1px solid var(--line); - border-radius: var(--radius); - padding: 16px; - min-width: 0; -} - -body.page-explainer .kpi { -display: grid; - gap: 4px; - min-height: 104px; -} - -body.page-explainer .kpi strong { -font-size: 30px; - line-height: 1; -} - -body.page-explainer .kpi span { -color: var(--muted); - font-size: 14px; -} - -body.page-explainer .tagrow { -display: flex; - flex-wrap: wrap; - gap: 6px; - margin-top: 10px; -} - -body.page-explainer .tag { -display: inline-flex; - align-items: center; - min-height: 24px; - border-radius: 999px; - padding: 2px 8px; - background: var(--soft); - border: 1px solid var(--line); - color: #34414c; - font-size: 12px; - font-weight: 650; -} - -body.page-explainer .callout { -border-left: 4px solid var(--blue); - background: #e4f0e9; - padding: 12px 14px; - border-radius: 0 6px 6px 0; - margin: 14px 0; -} - -body.page-explainer .callout.warning { -border-left-color: var(--gold); - background: #f6efda; -} - -body.page-explainer .page table { -width: 100%; - max-width: 100%; - display: block; - overflow-x: auto; - border-collapse: collapse; - background: var(--paper); - border: 1px solid var(--line); - border-radius: var(--radius); - overflow: hidden; - font-size: 14px; -} - -body.page-explainer .page th, body.page-explainer .page td { -padding: 10px 11px; - border-bottom: 1px solid var(--line); - text-align: left; - vertical-align: top; -} - -body.page-explainer .page th { -background: #f5f7f9; - color: #2a343d; - font-size: 12px; - text-transform: uppercase; - letter-spacing: 0.05em; -} - -body.page-explainer .page tr:last-child td { -border-bottom: 0; -} - -body.page-explainer .page code { -font-family: var(--mono); - font-size: 0.92em; - background: #eef2f4; - color: var(--green); - padding: 1px 5px; - border-radius: 5px; -} - -body.page-explainer .page pre { -margin: 0; - max-width: 100%; - background: var(--code-bg); - color: var(--code-ink); - border-radius: var(--radius); - padding: 14px; - overflow-x: auto; - font-family: var(--mono); - font-size: 13px; - line-height: 1.45; - white-space: pre; -} - -body.page-explainer .page pre code { -background: transparent; - color: inherit; - padding: 0; - border-radius: 0; - font-size: inherit; -} - -body.page-explainer .page pre.tree-map { -background: #ffffff; - color: var(--green); - border: 1px solid var(--line); - box-shadow: inset 4px 0 0 var(--green); -} - -body.page-explainer .page pre.tree-map code { -color: inherit; -} - -body.page-explainer .page pre.tree-map::selection, body.page-explainer .page pre.tree-map code::selection { -background: #235b8e; - color: #ffffff; -} - -body.page-explainer .diagram { -width: 100%; - height: auto; - background: var(--paper); - border: 1px solid var(--line); - border-radius: var(--radius); - box-shadow: var(--shadow); -} - -body.page-explainer .diagram-frame { -margin: 0; - overflow-x: auto; - background: var(--paper); - border: 1px solid var(--line); - border-radius: var(--radius); - box-shadow: var(--shadow); - padding: 12px; -} - -body.page-explainer .diagram-frame img { -display: block; - width: 100%; - min-width: 920px; - height: auto; -} - -body.page-explainer .diagram-frame figcaption { -color: var(--muted); - font-size: 13px; - margin-top: 10px; -} - -body.page-explainer .svg-title { -font: 700 14px var(--sans); fill: #1d252c; -} - -body.page-explainer .svg-text { -font: 13px var(--sans); fill: #2f3b44; -} - -body.page-explainer .svg-small { -font: 11px var(--sans); fill: #52616d; -} - -body.page-explainer .svg-box { -fill: #fffdfa; stroke: #bcb5a8; stroke-width: 1.2; -} - -body.page-explainer .svg-core { -fill: #e9f1f6; stroke: #235b8e; -} - -body.page-explainer .svg-tool { -fill: #eaf4ec; stroke: #2f6f50; -} - -body.page-explainer .svg-ext { -fill: #f7eed4; stroke: #8b6b22; -} - -body.page-explainer .svg-err { -fill: #f7e7e4; stroke: #a43e3e; -} - -body.page-explainer .svg-decision { -fill: #fff7d8; stroke: #8b6b22; stroke-width: 1.2; -} - -body.page-explainer .arrow { -stroke: #4d5963; stroke-width: 1.5; fill: none; marker-end: url(#arrowhead); -} - -body.page-explainer .arrow-dashed { -stroke-dasharray: 5 4; -} - -body.page-explainer .step-dot { -fill: #1d252c; -} - -body.page-explainer .step-num { -font: 700 11px var(--sans); fill: #ffffff; text-anchor: middle; dominant-baseline: central; -} - -body.page-explainer .page details { -background: var(--paper); - border: 1px solid var(--line); - border-radius: var(--radius); - padding: 0; -} - -body.page-explainer .page details + details { -margin-top: 10px; -} - -body.page-explainer .page summary { -cursor: pointer; - padding: 12px 14px; - font-weight: 750; -} - -body.page-explainer .page details .inside { -padding: 0 14px 14px; -} - -body.page-explainer .page ul, body.page-explainer .page ol { -margin-top: 0; padding-left: 22px; -} - -body.page-explainer .page li { -margin: 6px 0; -} - -body.page-explainer .page > footer { -border-top: 1px solid var(--line); - padding-top: 18px; - color: var(--muted); - font-size: 13px; -} - -@media (max-width: 780px) { -body.page-explainer .page { -padding: 24px 16px 42px; -} - -body.page-explainer .page h1 { -font-size: 32px; -} - -body.page-explainer .grid.cols-2, body.page-explainer .grid.cols-3 { -grid-template-columns: 1fr; -} - -body.page-explainer .page table { -font-size: 13px; -} - -body.page-explainer .page th, body.page-explainer .page td { -padding: 8px; -} -} - -@media print { -body.page-explainer { -background: white; -} - -body.page-explainer .page { -max-width: none; padding: 18px; -} - -body.page-explainer .page > header nav { -display: none; -} - -body.page-explainer .panel, body.page-explainer .diagram { -box-shadow: none; -} - -body.page-explainer .page pre { -white-space: pre-wrap; background: #f0f0f0; color: #111; -} -} - -/* ============================================================ - Transcripts page - ============================================================ */ - -:root { ---bg: #eceff2; - --panel: #ffffff; - --ink: #19222b; - --muted: #69757f; - --line: #d5dde3; - --accent: #1f7a4d; - --agent: #e4f0e9; - --assistant: #eaf2f8; - --tool: #f6efda; - --subagent: #f3ece2; - --error: #f6e6e4; - --ok: #1f7a4d; - --bad: #9d3a35; - --code-bg: #f5f7f9; - --radius: 10px; - --mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace; - --sans: "Inter Tight", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; -} - -body.page-transcripts * { -box-sizing: border-box; -} - -body.page-transcripts { -margin: 0; - background: var(--bg); - color: var(--ink); - font-family: var(--sans); - font-size: 15px; - line-height: 1.45; - -webkit-font-smoothing: antialiased; - background-image: - linear-gradient(#e3e9ed 1px, transparent 1px), - linear-gradient(90deg, #e3e9ed 1px, transparent 1px); - background-size: 64px 64px; -} - -body.page-transcripts header.page-title { -width: min(1180px, calc(100vw - 48px)); - margin: 0 auto; - padding: 30px 0 24px; - border-bottom: 1px solid var(--line); -} - -body.page-transcripts .page-title .eyebrow { -font-family: var(--mono); - font-size: 13px; - color: var(--accent); - margin-bottom: 10px; - letter-spacing: 0.02em; - font-weight: 500; -} - -body.page-transcripts h1 { -margin: 0 0 4px; font-size: 23px; letter-spacing: 0; -} - -body.page-transcripts .page-title h1 { -margin: 0 0 12px; - font-size: 40px; - line-height: 1.06; - letter-spacing: -0.025em; - font-weight: 700; -} - -body.page-transcripts .page-title .muted { -max-width: 820px; - color: #3a4650; - font-size: 18px; -} - -body.page-transcripts h2 { -margin: 0 0 4px; font-size: 21px; -} - -body.page-transcripts h3 { -margin: 0 0 6px; font-size: 14px; color: var(--muted); -} - -body.page-transcripts button, body.page-transcripts input, body.page-transcripts select { -font: inherit; - border: 1px solid var(--line); - border-radius: 6px; - background: #fff; - color: var(--ink); - padding: 8px 10px; -} - -body.page-transcripts button { -cursor: pointer; -} - -body.page-transcripts button:focus, body.page-transcripts input:focus, body.page-transcripts select:focus, body.page-transcripts summary:focus { -outline: 2px solid var(--accent); - outline-offset: 2px; -} - -body.page-transcripts .muted { -color: var(--muted); -} - -body.page-transcripts .layout { -display: grid; - grid-template-columns: 310px minmax(0, 1fr); - min-height: calc(100vh - 50px); -} - -body.page-transcripts .transcript-layout-single { -grid-template-columns: minmax(0, 1fr); - min-height: auto; -} - -body.page-transcripts .transcript-layout-single main { -width: min(1180px, calc(100vw - 48px)); - margin: 0 auto; -} - -body.page-transcripts aside { -background: var(--panel); - border-right: 1px solid var(--line); - padding: 16px; - position: sticky; - top: 50px; - height: calc(100vh - 50px); - overflow: auto; -} - -body.page-transcripts main { -padding: 18px 24px 40px; min-width: 0; -} - -body.page-transcripts .controls { -display: grid; gap: 10px; margin-bottom: 14px; -} - -body.page-transcripts .transcript-toolbar { -grid-template-columns: minmax(260px, 1fr) 190px auto; - align-items: center; - background: var(--panel); - border: 1px solid var(--line); - border-radius: var(--radius); - padding: 12px; -} - -body.page-transcripts .agent-list { -display: grid; gap: 8px; -} - -body.page-transcripts .agent-button { -width: 100%; - text-align: left; - display: grid; - gap: 2px; - border-radius: var(--radius); -} - -body.page-transcripts .agent-button.active { -background: var(--accent); - border-color: var(--accent); - color: #fff; -} - -body.page-transcripts .agent-button.active .muted { -color: #d3eade; -} - -body.page-transcripts .stats { -display: grid; - grid-template-columns: repeat(6, minmax(0, 1fr)); - gap: 10px; - margin-bottom: 14px; -} - -body.page-transcripts .stat { -background: var(--panel); - border: 1px solid var(--line); - border-radius: var(--radius); - padding: 12px; -} - -body.page-transcripts .stat strong { -display: block; font-size: 20px; -} - -body.page-transcripts .agent-card { -background: var(--panel); - border: 1px solid var(--line); - border-radius: var(--radius); - padding: 16px; - margin-bottom: 18px; -} - -body.page-transcripts .agent-head { -display: flex; - justify-content: space-between; - gap: 16px; - padding-bottom: 12px; - margin-bottom: 14px; - border-bottom: 1px solid var(--line); -} - -body.page-transcripts .audit-spec { -border: 1px solid var(--line); - border-radius: var(--radius); - background: #fbfcfd; - padding: 12px; - margin-bottom: 12px; - display: grid; - gap: 14px; -} - -body.page-transcripts .audit-section h3 { -margin-bottom: 8px; - color: var(--ink); - font-size: 13px; - text-transform: uppercase; - letter-spacing: .02em; -} - -body.page-transcripts .audit-section ol { -margin: 0; - padding-left: 22px; -} - -body.page-transcripts .audit-section li + li { -margin-top: 4px; -} - -body.page-transcripts .system-prompt { -margin-top: 16px; -} - -body.page-transcripts .tool-overview { -border: 1px solid var(--line); - border-radius: var(--radius); - background: #fbfcfd; - padding: 12px; - margin: 12px 0; -} - -body.page-transcripts .tool-overview h3 { -margin: 0 0 10px; - color: var(--ink); - font-size: 13px; - text-transform: uppercase; - letter-spacing: .02em; -} - -body.page-transcripts .tool-overview-grid { -display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 10px; -} - -body.page-transcripts .tool-overview-item { -border: 1px solid var(--line-2); - border-radius: 8px; - background: #fff; - padding: 10px; - min-width: 0; -} - -body.page-transcripts .tool-overview-item h4 { -margin: 0 0 7px; - font-size: 14px; - color: var(--ink); -} - -body.page-transcripts .tool-overview-item .plain-text { -margin-top: 8px; - color: var(--ink-soft); - font-size: 13px; -} - -body.page-transcripts .pill-row { -display: flex; flex-wrap: wrap; gap: 6px; -} - -body.page-transcripts .pill { -display: inline-flex; - align-items: center; - border: 1px solid var(--line); - border-radius: 999px; - padding: 3px 8px; - color: var(--muted); - background: #fff; - font-size: 12px; -} - -body.page-transcripts .pill.ok { -color: var(--ok); border-color: #b8d9bf; background: #f0faf2; -} - -body.page-transcripts .pill.error { -color: var(--bad); border-color: #e3b9b7; background: var(--error); -} - -body.page-transcripts .transcript { -display: grid; - gap: 12px; - margin-top: 16px; -} - -body.page-transcripts .event { -border: 1px solid var(--line); - border-radius: var(--radius); - background: #fff; - overflow: hidden; - min-width: 0; -} - -body.page-transcripts .event.error { -border-color: #e0b4b2; background: var(--error); -} - -body.page-transcripts .event-head { -display: flex; - justify-content: space-between; - gap: 12px; - align-items: center; - padding: 10px 12px; - border-bottom: 1px solid var(--line); -} - -body.page-transcripts .event-title { -display: flex; gap: 8px; align-items: center; min-width: 0; -} - -body.page-transcripts .badge { -font-family: var(--mono); - text-transform: uppercase; - font-size: 11px; - border-radius: 5px; - padding: 3px 6px; - white-space: nowrap; -} - -body.page-transcripts .badge.agent { -background: var(--agent); color: var(--ok); -} - -body.page-transcripts .badge.chat { -background: var(--assistant); color: #075a71; -} - -body.page-transcripts .badge.tool { -background: var(--tool); color: #765700; -} - -body.page-transcripts .badge.subagent { -background: var(--subagent); color: #7a4618; -} - -body.page-transcripts .badge.error { -background: var(--error); color: var(--bad); -} - -body.page-transcripts .duration { -color: var(--muted); - font-family: var(--mono); - font-size: 12px; - white-space: nowrap; -} - -body.page-transcripts .event-body { -padding: 12px; display: grid; gap: 12px; min-width: 0; -} - -body.page-transcripts .bubble { -border: 1px solid var(--line); - border-radius: var(--radius); - padding: 11px 12px; - background: #fff; - min-width: 0; - overflow-wrap: anywhere; -} - -body.page-transcripts .bubble.assistant { -background: var(--assistant); border-color: #c8e3eb; -} - -body.page-transcripts .bubble.tool-call { -background: var(--tool); border-color: #ecdca6; -} - -body.page-transcripts .bubble.tool-result { -background: #fffdf4; border-color: #ecdca6; -} - -body.page-transcripts .bubble.subagent { -background: var(--subagent); border-color: #edcfb8; -} - -body.page-transcripts .bubble.final { -background: #effaf2; border-color: #badbc1; -} - -body.page-transcripts .bubble.context { -background: #f8fafb; -} - -body.page-transcripts .write-input .plain-text { -font-family: var(--mono); - font-size: 12px; - line-height: 1.5; - max-height: 480px; - overflow: auto; - background: rgba(255,255,255,.58); - border: 1px solid rgba(214,179,65,.35); - border-radius: 6px; - padding: 10px; -} - -body.page-transcripts .write-path { -font-family: var(--mono); - font-size: 12px; - color: #5f4705; - background: rgba(255,255,255,.6); - border: 1px solid rgba(214,179,65,.35); - border-radius: 6px; - padding: 6px 8px; - margin-bottom: 8px; - overflow-wrap: anywhere; -} - -body.page-transcripts .compact-call > div:not(.label) > strong, -body.page-transcripts .compact-call > div:not(.label) > .muted { -display: block; - min-width: 0; - overflow-wrap: anywhere; -} - -body.page-transcripts .label { -display: flex; - justify-content: space-between; - gap: 10px; - margin-bottom: 6px; - color: var(--muted); - font-size: 12px; - text-transform: uppercase; - letter-spacing: .02em; - font-weight: 700; -} - -body.page-transcripts .message-list { -display: grid; gap: 8px; min-width: 0; -} - -body.page-transcripts .message { -border-left: 3px solid var(--line); - padding: 7px 9px; - background: #fff; - border-radius: 0 6px 6px 0; - min-width: 0; -} - -body.page-transcripts .message.user { -border-color: #7da8b6; -} - -body.page-transcripts .message.assistant { -border-color: #6baec2; -} - -body.page-transcripts .message.tool { -border-color: #d6b341; -} - -body.page-transcripts .tool-result-list { -display: grid; gap: 6px; min-width: 0; -} - -body.page-transcripts .tool-result-row summary { -display: flex; - justify-content: space-between; - gap: 12px; - align-items: center; -} - -body.page-transcripts .tool-result-row .summary-main { -min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -body.page-transcripts .tool-result-row .call-id { -color: var(--muted); - font-family: var(--mono); - font-size: 11px; - white-space: nowrap; -} - -body.page-transcripts .role { -display: block; - color: var(--muted); - font-size: 12px; - font-weight: 700; - text-transform: uppercase; - margin-bottom: 4px; -} - -body.page-transcripts details { -border: 1px solid var(--line); - border-radius: 6px; - background: #fff; - min-width: 0; -} - -body.page-transcripts summary { -cursor: pointer; - padding: 8px 10px; - color: var(--muted); - font-weight: 650; - overflow-wrap: anywhere; -} - -body.page-transcripts .details-body { -padding: 0 10px 10px; - min-width: 0; -} - -body.page-transcripts .output-details .details-body { -padding-top: 0; -} - -body.page-transcripts .output-details pre { -max-height: 720px; -} - -body.page-transcripts .nested-events { -background: #fbfcfd; - border-color: var(--line); -} - -body.page-transcripts .nested-events > summary { -font-family: var(--mono); - font-size: 12px; - color: var(--ink-soft); -} - -body.page-transcripts .nested-events-body { -display: grid; - gap: 10px; - padding: 0 10px 10px 16px; - border-left: 2px solid var(--line); - margin-left: 10px; -} - -body.page-transcripts .nested-events-body .event { -box-shadow: none; -} - -body.page-transcripts pre { -margin: 0; - padding: 10px; - background: var(--code-bg); - color: #10202b; - border-radius: 6px; - overflow: auto; - max-height: 560px; - white-space: pre-wrap; - word-break: break-word; - font-family: var(--mono); - font-size: 12px; - line-height: 1.45; -} - -body.page-transcripts .plain-text { -white-space: pre-wrap; - word-break: break-word; -} - -body.page-transcripts .empty { -color: var(--muted); padding: 12px; -} - -body.page-transcripts .layout footer { -margin-top: 24px; color: var(--muted); font-size: 12px; -} - -@media (max-width: 860px) { -body.page-transcripts header.page-title { -width: calc(100vw - 32px); - padding: 24px 0 22px; -} - -body.page-transcripts .page-title h1 { -font-size: 32px; -} - -body.page-transcripts .layout { -grid-template-columns: 1fr; -} - -body.page-transcripts aside { -position: static; - height: auto; - border-right: 0; - border-bottom: 1px solid var(--line); -} - -body.page-transcripts .stats { -grid-template-columns: repeat(2, minmax(0, 1fr)); -} - -body.page-transcripts .tool-overview-grid { -grid-template-columns: 1fr; -} - -body.page-transcripts .agent-head, body.page-transcripts .event-head { -display: grid; -} -} - -@media print { -body.page-transcripts header.page-title, body.page-transcripts aside { -position: static; -} - -body.page-transcripts .layout { -display: block; -} - -body.page-transcripts .controls { -display: none; -} - -body.page-transcripts .event, body.page-transcripts .agent-card { -break-inside: avoid; -} - -body.page-transcripts { -background: #fff; -} -} - -/* ---- examples page: LongMemEval / transcript toggle ---- */ -.ex-toggle{display:flex;justify-content:center;gap:8px;width:min(1180px,calc(100vw - 48px));margin:18px auto 6px;} -.ex-toggle button{font-family:var(--mono);font-size:13px;padding:8px 16px;border:1px solid var(--line);border-radius:8px;background:var(--panel);color:var(--ink-soft);cursor:pointer;transition:color .15s,background .15s,border-color .15s;} -.ex-toggle button:hover{color:var(--green-deep);border-color:var(--green-line);} -.ex-toggle button.is-active{color:var(--green-deep);background:var(--green-wash);border-color:var(--green-line);font-weight:600;} -.ex-panel[hidden]{display:none !important;} -.md-render{width:min(860px,calc(100vw - 48px));margin:0 auto;padding:14px 0 64px;color:var(--ink);font-family:var(--sans);line-height:1.62;} -.md-render .md-eyebrow{font-family:var(--mono);font-size:13px;color:var(--green);letter-spacing:.04em;font-weight:500;margin:6px 0 6px;} -.md-render h1{font-size:30px;margin:0 0 8px;letter-spacing:-.02em;font-weight:700;} -.md-render h2{font-size:20px;margin:30px 0 10px;color:var(--green-deep);border-bottom:1px solid var(--line);padding-bottom:6px;} -.md-render h3{font-size:16px;margin:20px 0 8px;} -.md-render p{margin:10px 0;color:var(--ink-soft);} -.md-render ul{margin:10px 0;padding-left:22px;color:var(--ink-soft);} -.md-render li{margin:7px 0;} -.md-render a{color:var(--green);text-decoration:underline;text-underline-offset:2px;} -.md-render code{font-family:var(--mono);font-size:.86em;background:var(--green-wash);padding:1px 5px;border-radius:4px;color:var(--green-deep);} -.md-render strong{color:var(--ink);} -.md-table-wrap{overflow-x:auto;margin:14px 0;border:1px solid var(--line);border-radius:8px;max-width:620px;} -.md-render table{width:100%;border-collapse:collapse;font-size:13px;} -.md-render th,.md-render td{padding:8px 12px;border-bottom:1px solid var(--line);text-align:left;vertical-align:top;} -.md-render td:first-child,.md-render th:first-child{font-family:var(--mono);font-size:12px;color:var(--ink);white-space:nowrap;} -.md-render thead th{background:var(--green-wash);color:var(--green-deep);font-weight:600;} -.md-render tbody tr:last-child td{border-bottom:none;} diff --git a/docs/site/assets/thinharness-mark.svg b/docs/site/assets/thinharness-mark.svg deleted file mode 100644 index 36dd8cb..0000000 --- a/docs/site/assets/thinharness-mark.svg +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - - diff --git a/docs/site/assets/thinharness-run-loop.svg b/docs/site/assets/thinharness-run-loop.svg deleted file mode 100644 index 6ca53e9..0000000 --- a/docs/site/assets/thinharness-run-loop.svg +++ /dev/null @@ -1,3 +0,0 @@ - - -
harness/runtime
provider session
decision
approval pause
tool path
final result
retry / stop
stream event
Typed event stream
StreamEvent emitted at each blue dot; contains kind, run_id, and event number
SETUP
ONE MODEL TURN
BRANCH HANDLING INSIDE THE WHILE LOOP
Entry point
Harness.stream()
core.py:305-374
outer stream flow
Create run objects
RunContext
StreamEmitter
core.py:405-418
per-run state
trace scope
Open session
new_session()
resume_session()
core.py:448-491
one active session
Loop entry
RunContext.advance_model()
runtime.py:268
limits, notices, tracing
provider request
Call provider session
ModelSession
providers.py:184
start()
continue_with_tools()
continue_with_user_text()
Model turn
ModelTurn
providers.py:56
text
tool calls
raw JSON
Resolve turn
resolve_turn_output()
turns.py:50
OutputTurnDecision
chooses next branch
Approval required?
_approval_required_calls()
turns.py:322
before tool batch executes
Final result
RunContext.finalize()
runtime.py:420
builds HarnessResult
fires run_end once
Approval pause
pause_for_approval()
runtime.py:367
stop_reason="approval_required"
pending approvals + resume_state
no tools executed
Run tools
ToolBatchExecutor.execute_batch()
tool_execution.py:63
runs ordinary tool calls
preserves model order
Return tool outputs
Build ToolOutput[]
providers.py:97
model-visible results
Retry decision
RunContext.retry_or_fail()
runtime.py:448
budget remains or stop
Stop / raise
core.py:499-510
HarnessError
limit, provider,
or validation
Corrective request
continue_with_tools()
continue_with_user_text()
asks model to fix output
interpret turn
final
no approval
requires approval
tool batch
schema retry
next iteration
budget remains
no retry
next iteration
Text is not SVG - cannot display
\ No newline at end of file diff --git a/docs/site/examples/index.html b/docs/site/examples/index.html deleted file mode 100644 index 3a207a4..0000000 --- a/docs/site/examples/index.html +++ /dev/null @@ -1,637 +0,0 @@ - - - - - - Examples — ThinHarness - - - - - - -
-
// examples
-

Examples

-
Real ThinHarness runs. Toggle between the LongMemEval-V2 benchmark write-up and the Web Research transcript.
-
-
- - -
-
-
// benchmark reproduction

LongMemEval-V2 - ThinHarness Benchmark

-

Context

-

I used a fork of the LongMemEval-V2 benchmark to test whether ThinHarness is a strong general-purpose agent harness on a nontrivial non-coding task: information retrieval over long trajectory haystacks.

-

I scoped the comparison to the 127 dynamic questions in the small tier: dynamic-environment and dynamic-environment-abs across the web and enterprise domains. After reviewing the benchmark categories, this subset looked like the best test for my purposes: given a long history of interaction traces, can the memory layer efficiently and accurately find the state change, UI behavior, or environment fact needed to answer the question?

-

Run Details

-

To get a local baseline for more detailed metrics than the leaderboard provides, I ran AgentRunbook-C with the resumable wrapper in evaluation/scripts/run_agentrunbook_c_dynamic.sh. That script plans the 127-question dynamic set, runs one question per output directory, and can be re-run to fill only missing questions. Running one question at a time is slower but was helpful when encountering intermittent issues running on my local machine. The matching ThinHarness wrapper is evaluation/scripts/run_thinharness_dynamic.sh.

-

This is a best-faith reproduction rather than an exact reproduction of the paper. The paper setup expects a local Qwen/Qwen3.5-9B reader deployment; I used qwen/qwen3.5-9b through OpenRouter. The paper uses Codex v0.117.0 for Codex and AgentRunbook-C; my rerun used the local Codex CLI available, codex-cli 0.141.0. Both the AgentRunbook-C rerun and ThinHarness run used gpt-5.4-mini with xhigh reasoning for the query-time memory agent and gpt-5.2 for the evaluator.

-

For the ThinHarness run, I used only its generic built-in filesystem tools (read, search, jsonl_search, list, and glob). I did restructure the memory files into a JSONL-friendly corpus so its generic jsonl_search tool could work well; that seems acceptable here because AgentRunbook-C also creates a custom trajectory structure rather than using the vanilla Codex raw layout. I tried to keep the query-time system prompt as close as practical to AgentRunbook-C, changing the tool instructions only where ThinHarness needed to know how to use its built-in tools.

-

Results

-

Across all 127 dynamic questions in the small tier:

-
MetricAgentRunbook-C rerunThinHarness
Dynamic score72.4% (92/127)74.0% (94/127)
Non-abstention86.0% (74/86)84.9% (73/86)
Abstention43.9% (18/41)51.2% (21/41)
Memory query time151.9s avg, 129.1s median99.7s avg, 87.9s median
Memory-agent tokens / usage114.77M input, 1.32M output, 116.09M total60.14M input, 2.10M output, 62.24M total
Dynamic-subset LAFS3.769.73 (+5.96)
-

The 72.4% accuracy for AgentRunbook-C matches the paper, but I would not treat this single consolidated run as a statistically signficant claim that ThinHarness has higher accuracy than AgentRunbook-C--I saw meaningful variance on a portion of the questions when doing targeted reruns. The result does make me reasonably confident that ThinHarness at least matches AgentRunbook-C's performance on this slice, and the published leaderboard reference for vanilla Codex is materially lower than both.

-

The memory query time is the harness-measured time around memory.query(...): it includes the query-time memory retrieval agent, but not the downstream reader, scorer, or prior runtime input generation. The timing comparison isn't perfect (local Codex CLI vs. OpenAI API), but the 46.4% lower token usage indicates that ~34% time savings is probably in the right ballpark. Note that the paper only provides a single aggregate query time figure across all questions, 108.3s, which is far lower than the 151.9s above but includes all questions in the small tier (some of which may have been faster).

-
- - - - - - - diff --git a/docs/site/explainer/index.html b/docs/site/explainer/index.html deleted file mode 100644 index b40199d..0000000 --- a/docs/site/explainer/index.html +++ /dev/null @@ -1,948 +0,0 @@ - - - - - - Codebase Explainer — ThinHarness - - - - - - -
-
-
// codebase guide
-

A mental model for the whole repository

-

- ThinHarness is a focused agent harness with a small runtime surface. The same loop runs no matter which - provider adapter is used: create a model session, ask the model for a turn, convert - provider-specific responses into ThinHarness objects, execute local tools, send tool - results back, and stop when the final-answer code says the run is complete. -

- -
- -
-

Snapshot

-
-
- 24 - runtime Python files under thinharness/, including the tools package. -
-
- 8,035 - README-stated framework LOC, intentionally small enough to inspect, adapt, and fork. -
-
- 8 - hook events covering run start, prompt submit, tools, subagents, limits, and run end. -
-
-
-

- ThinHarness separates reusable setup from per-run state. HarnessConfig is Pydantic - configuration; Harness owns configured runtime objects; RunContext owns one - invocation's mutable state; provider ModelSession objects own provider conversation state; - ModelTurn and ToolSpec are small dataclass contracts passed across those boundaries. -

-
-
-

Where this page starts

-

- For positioning, quick-start usage, and feature comparison, read README.md. This page focuses on - runtime architecture and code ownership. -

-
-
- -
-

Run Loop

-
- SVG flowchart showing the ThinHarness run loop from Harness.run through RunContext, ModelSession, ModelTurn resolution, tool execution, approval pause, retries, and HarnessResult construction. -
- Tool calls execute through the same local tool path, then their normalized outputs are returned to the provider before the loop resolves the next model turn. -
-
- -
-
-

Happy path

-
    -
  1. Harness.run() checks that the harness can run, creates or resumes a ModelSession, and builds RunContext.
  2. -
  3. RunContext.advance_model() makes one provider call with limit checks, notices, usage accounting, and model tracing around it.
  4. -
  5. The provider adapter returns a ModelTurn, ThinHarness's common Python object for model text, tool calls, and raw provider JSON.
  6. -
  7. resolve_turn_output() reads that turn against OutputSchema and chooses the next action: final result, run tools, structured-output retry, or failure.
  8. -
  9. If any requested tool requires approval, the harness pauses before the batch runs and returns stop_reason="approval_required" with pending approval records and an approval resume envelope.
  10. -
  11. If the decision is continue, ToolBatchExecutor runs ordinary tool calls and sends ordered ToolOutput values back through continue_with_tools().
  12. -
  13. If the decision is final, RunContext.finalize() builds HarnessResult, attaches resume state when valid, annotates tracing, and fires run_end.
  14. -
-
-
-

Failure and limit path

-
    -
  • Provider errors become HarnessError with stop_reason="provider_error".
  • -
  • Structured-output validation can request a corrective model turn until output_retries is exhausted.
  • -
  • Retryable tool envelopes increment per-tool retry counters and can stop the run with tool_retries_exceeded.
  • -
  • Tool batches that would exceed max_tool_calls are rejected before local execution.
  • -
  • run_end is guarded so success, errors, hook cancellation, and external cancellation fire it at most once.
  • -
-
-
-
- -
-

Streaming

-

- Harness.stream(...) is the public progress API, and Harness.run(...) is implemented - by consuming that stream until the top-level RunCompletedEvent arrives. Streaming does not change - the run loop; it exposes coarse lifecycle events from the same execution path. -

-
-
-

What streams

-
    -
  • Run start, provider request start, complete model turns, tool call start/completion, retry opportunities, limit warnings, run completion, and run failure.
  • -
  • Provider calls still return complete model turns. This is workflow progress streaming, not token-delta streaming.
  • -
  • The final successful event carries the full HarnessResult, including provider responses, tool records, usage, stop reason, output, and resume state.
  • -
-
-
-

Payload policy

-
    -
  • Stream events include high-level prompt, tool argument, and model-visible tool result payloads for app-facing workflow visibility.
  • -
  • Raw provider response JSON is not included in stream events. Use the terminal HarnessResult.responses after completion when provider raw responses are needed.
  • -
  • StreamOptions can hide child subagent events, but it does not hide model text or expose raw provider payloads.
  • -
  • Nested work is correlated with run_id, parent_run_id, parent_tool_call_id, agent_name, and per-stream sequence.
  • -
-
-
-
-

- stream() starts eagerly when called and owns an in-process event queue. If a caller may stop - before the terminal event, use the stream as an async context manager or call aclose(); closing - the stream cancels the underlying run task and lets the harness be reused cleanly. -

-
-
- -
-

Repository File Map

-
.
-|-- thinharness/
-|   |-- __init__.py                  public API exports
-|   |-- core.py                      HarnessConfig, Harness, run-loop coordination
-|   |-- runtime.py                   RunContext and the provider-call wrapper
-|   |-- turns.py                     turn state machine and output turn decisions
-|   |-- types.py                     leaf run result, usage, errors, stop reasons, Json alias
-|   |-- tool_execution.py            tool batch execution and one-call hook/tracing flow
-|   |-- providers.py                 provider transports, model adapters, session state
-|   |-- output.py                    structured-output schemas and validation
-|   |-- hooks.py                     hook dataclasses, registry, context variables
-|   |-- subagents.py                 subagent tool and child harness construction
-|   |-- tracing.py                   OTel-compatible spans and local JSONL tracing
-|   |-- defaults.py                  default filesystem-agent system prompt
-|   `-- tools/
-|       |-- __init__.py              tool package exports
-|       |-- base.py                  ToolSpec, ToolResult, path policy, invocation
-|       |-- filesystem.py            FileTools: read, write, edit, search, list, glob
-|       |-- jsonl.py                 JsonlSearch and JSONL where/projection helpers
-|       |-- search_support.py        shared ripgrep parsing and glob validation
-|       |-- mcp.py                   optional MCP transports and tool conversion
-|       |-- parallel_llm.py          ParallelLlmTool batch completions
-|       `-- skills.py                SkillRegistry, skill_read, skill_run
-|-- tests/                           pytest coverage for every feature area
-|-- e2e/                             live-provider journeys, intentionally outside CI
-|-- examples/                        shared scenario registry plus thin agent entrypoints
-|-- docs/                            decisions, user docs, site files, releasing notes
-|-- README.md                        motivation, usage, comparison table
-`-- pyproject.toml                   package metadata, deps, ruff, pyright, pytest
-
- -
-

Core Objects

-
-
-

Harness-facing objects

- - - - - - - - - - -
NameMeaningRelationship
HarnessConfigPydantic setup model: root, model ref, tool selection, limits, output mode, tracing, MCP, subagents, path policies.Configures a Harness.
HarnessLong-lived configured runner. It owns tool maps, model object, hooks, skill registry, MCP server list, and tracing configuration.Creates a fresh RunContext for each run.
RequestConstantsFrozen per-run request constants: instructions, tool schemas, metadata, and the structured-output request. Built once after run-start hooks and MCP connection, so the toolset is frozen for the run.Passed to every ModelSession request by the turn machine in turns.py.
RunContextInternal state for one Harness.run(...): responses, tool records, usage, retry/notice state, terminal error, stop reason, tracing span, and final result.References the reusable Harness, but is not stored on it after the run.
HarnessResultFinal run result: final text, parsed structured output, raw provider responses, tool call records, usage, stop reason, and resume state.Receives finalized state from RunContext.
RunUsageCounts model requests, tool calls, cancelled tool calls, output retries, per-tool retry counts, and run token totals (input_tokens/output_tokens).Per-run counter owned by RunContext and returned in HarnessResult.
-
-
-

Provider-neutral objects

- - - - - - - - - - -
NameMeaning
ModelProtocol for reusable model configuration. It creates isolated ModelSession objects.
ModelSessionPer-run provider conversation state. Built-in sessions keep native in-run state plus a parallel neutral transcript; dump_state returns the transcript for provider-agnostic resume. All expose three request methods — start, continue_with_tools, and continue_with_user_text — each taking per-run RequestConstants, plus dump_state.
ModelTurnNormalized provider response: assistant text, requested ModelToolCall entries, raw provider JSON, plus normalized TokenUsage, finish reason, and response model.
ModelToolCallNormalized tool request with id, name, and raw JSON argument string.
ToolOutputTool result sent back to the provider so the model can continue after a tool call: call id plus model-visible output string.
ModelNoticeProvider-neutral model input notice for run-budget warnings, including remaining model requests and remaining tool calls.
-
-
- -
- -
-

Default Tools

-

- Every model-callable tool is represented by ToolSpec, a dataclass that packages - the tool definition sent to the model with the Python callable that executes it. A spec includes - a name, description, JSON schema or Pydantic argument model, handler callable, sequential flag, - metadata, optional retry budget, and approval flag. - The result sent back to the provider is always a JSON envelope with ok, content, and metadata. -

- -
-
-

What the handler is

-

- ToolSpec.handler is the callable the harness invokes after it parses and validates - the model's JSON arguments. It can be a plain function, a bound method, a callable object, - or an async callable. The harness calls it as handler(args), then converts - a ToolResult, string, or JSON-serializable value into the output envelope sent back to the provider. -

-
-
-

Class to ToolSpec handoff

-

- Built-in tool modules often use classes to hold shared state, but the class itself is not - the model-callable tool. For example, FileTools.specs() creates several - ToolSpec objects whose handlers are bound methods such as self.read, - self.write, and self.search. Each bound method carries the configured - root path, path policies, limits, and spill behavior from that FileTools instance. -

-
-
-

Sequential flag

-

- sequential=True means calls involving that tool force the current model-emitted - tool-call batch to run serially instead of concurrently. The flag is used only inside ThinHarness, - not sent to the model as part of the tool JSON schema. With the default batch policy, one sequential - tool makes the whole batch run in model order. -

-
-
-

Built-in selection

-

- builtin_tools is a list of tool names. When omitted, ThinHarness exposes the default filesystem - tools. When provided, it selects from built-in candidates such as filesystem tools, skill_read, - skill_run, subagent, and parallel_llm. -

-
-
- -
-

Tool surfaces

- - - - - - - - - - - - - - - - - - - - - - - - -
SurfaceClass or ownerHow it enters the harnessImportant behavior
FilesystemFileToolsbuiltin_tools(root, ...) returns FileTools(root).specs().Default exposed set is read, write, edit, search, list, glob. Mutating tools are marked sequential=True.
Framework-providedVaries: skills, subagents, MCP adapters, JSONL search, and ParallelLlmToolBuilt-in candidates, configured extras, or discovered MCP tools are converted into ToolSpec objects and added to the same runtime tool map.Optional surfaces still use normal tool execution. The Extras section covers feature-specific ownership and constraints.
CustomCaller-provided ToolSpecRegistered at construction with tools=[...] or later with add_tool(); both populate the same runtime tool map.Sync handlers run in worker threads. Async handlers run directly. Pydantic args turn validation failures into retry envelopes. Human approval is opt-in through requires_approval=True.
-
- -
-
-

Invocation path

-
    -
  • ToolCallExecutor.execute_one(call) sets the current tool-call context.
  • -
  • before_tool_call hooks can cancel before local execution.
  • -
  • Arguments are parsed from JSON and validated with Pydantic when the tool has an argument model.
  • -
  • The handler runs directly when async, or in a worker thread when sync.
  • -
  • The result is normalized into the standard {"ok", "content", "metadata"} envelope.
  • -
  • after_tool_call hooks can rewrite model-visible output before tracing records the result.
  • -
-
-
-

Retry semantics

-
    -
  • Malformed JSON args, non-object args, and Pydantic validation errors return retryable envelopes.
  • -
  • A handler can raise ModelRetry to ask the model to retry with a hint.
  • -
  • Ordinary handler exceptions become failed tool results but are not retryable unless metadata says so.
  • -
  • Tool retry budgets are per tool name per run, not per individual call id.
  • -
  • after_tool_call hooks can rewrite output text, but retry control flow is captured before that mutation.
  • -
-
-
-
- -
-

Providers and Sessions

-

- Provider classes own auth, base URL, HTTP client setup/cleanup, and request posting. - Model classes own static settings and create session objects. Session classes own mutable conversation state. -

- - - - - - - - - - - - - - - - - - - - - - - - - - - - -
ProviderTransport classModel classSession stateStructured-output default
OpenAI ResponsesOpenAIProviderOpenAIResponsesModelLive previous_response_id chaining plus neutral transcript statenative: ask OpenAI directly for JSON-schema output.
Anthropic MessagesAnthropicProviderAnthropicMessagesModelLive system/messages plus neutral transcript statetool: use the harness-created final_result tool because Anthropic native JSON-schema output is not supported here.
OpenRouter chat completionsOpenRouterProviderOpenRouterModelLive chat messages plus neutral transcript statetool by default; explicit native mode is passed through as OpenRouter response_format.
- -
-

- Resume state is intentionally provider-owned and strictly validated. It checks kind, version, model, - known fields, field types, unknown keys, and JSON serializability. It does not verify that tools or system - prompts match the original run; callers own that compatibility. -

-
- -
- Provider payload conversion helpers worth knowing -
-
    -
  • infer_model("provider:model") selects the adapter and creates provider/settings objects.
  • -
  • ModelNotice values are rendered with render_model_notices() and inserted into provider input by each session method; prompt starts/corrections append text, while tool continuations may add notice content after tool outputs.
  • -
  • _responses_tool_to_anthropic() and _responses_tool_to_chat() convert the common function-tool schema.
  • -
  • _extract_responses_tool_calls(), _extract_anthropic_tool_calls(), and _extract_chat_tool_calls() convert provider-specific tool calls into ModelToolCall objects.
  • -
-
-
-
- -
-

Lifecycle And Observability

-

- Hooks and tracing are runtime surfaces, not model-callable tools. The harness works with no registered hooks, - but the hook points are part of the run lifecycle. Tracing records the lifecycle through spans; it should not - change control flow. -

-
-
-

Hooks

-

- Hooks are runtime-only callables registered as Hook(event, handler, tools=None, agents=None). - Dispatch is synchronous and ordered, so earlier hooks can deterministically cancel or mutate context before - later hooks run. Tool filters only apply to tool events; agent filters only apply to subagent events. - Limit and retry logic is not implemented through hooks; hard limit events notify hooks after the runtime - detects the limit condition. -

- - - - - - - - - - - - -
EventCan cancel?Can mutate?Filter
run_startNoNoNone
user_prompt_submitYesAdd prompt contextNone
before_tool_callYesNoTool name
after_tool_callNoRewrite model-visible outputTool name
before_subagent_runYesNoAgent name
after_subagent_runNoNoAgent name
limit_reachedNoNoNone
run_endNoNoNone
-
-
-

Tracing

-

- RunTracer opens agent, model, and tool spans across every configured TracingOptions sink. - Local tracing is enabled by default for top-level harnesses unless THINHARNESS_DISABLE_LOCAL_TRACING disables it. - Trace attributes follow the OpenTelemetry GenAI semantic conventions used by the local tracing implementation. -

-
    -
  • Local traces are JSONL span records under the configured trace directory.
  • -
  • OTLP tracing is optional through the tracing extra.
  • -
  • Span creation and content capture are separate: external tracing can record spans without recording prompts, tool args, or tool results unless those capture flags are enabled.
  • -
-
-
-
- -
-

Extras

-

- These surfaces are not required for ordinary harness runs. They still use the same Harness and - ToolSpec machinery, but applications can ignore them unless they need specialized search, - skills, delegation, MCP, or one-shot model fan-out. -

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
ExtraHow it enters the runBoundary / behavior
JSONL searchFileTools.__init__ creates self.jsonl, and FileTools.specs() includes self.jsonl.spec().Specialized search over large JSONL content stores: ripgrep row prefiltering, field paths, equality/contains/regex/range filters, projection, field-level snippets, row limits, and truncation through FileTools spill behavior.
Skillsskills_dir discovers available skills, but the model only gets skill tools when builtin_tools includes skill_read or skill_run.Skills add prompt summaries plus skill_read and skill_run; they do not create one tool per skill.
SubagentsThe subagent framework tool builds a child Harness and returns the child result as a tool result.The parent tool call awaits the child run. Child harnesses always receive subagents=[], structurally disabling recursion.
MCPConfigured server objects connect lazily and discover tools into live ToolSpec objects.Subagents do not inherit parent-discovered MCP tools as ordinary custom ToolSpec objects. A named subagent can opt into MCP with inherit_mcp_servers=True or its own mcp_servers=[...]; then the child harness discovers MCP tools through its own connection lifecycle against those configured server objects.
Parallel LLMAvailable built-in tool candidate via create_parallel_llm_tool(parent), or custom renameable ParallelLlmTool(...).spec().Runs independent one-shot prompts concurrently. The built-in is configured by application code and text-only; custom ParallelLlmTool can opt into structured output.
-
- -
-

Recommended Reading Path

-
    -
  1. README.md for motivation and constraints: reusable loop primitives, small runtime surface, provider-agnostic, no shell by default.
  2. -
  3. thinharness/tools/base.py to understand ToolSpec, ToolResult, argument validation, retry envelopes, and path policy.
  4. -
  5. thinharness/providers.py through the common dataclasses/protocols, then skim each provider session.
  6. -
  7. thinharness/output.py so final-answer decisions make sense before reading the loop.
  8. -
  9. thinharness/core.py, thinharness/turns.py, and thinharness/runtime.py together. Read Harness.__init__, then Harness.run, then advance_until_terminal and RunContext.advance_model.
  10. -
  11. thinharness/tool_execution.py to see how a model-emitted batch becomes ordered provider outputs.
  12. -
  13. thinharness/tools/filesystem.py to understand the default workspace tools the model can call.
  14. -
  15. thinharness/hooks.py and thinharness/tracing.py to understand lifecycle callbacks and run observability.
  16. -
  17. Pick extras as needed: thinharness/tools/jsonl.py, thinharness/subagents.py, thinharness/tools/mcp.py, thinharness/tools/skills.py, and thinharness/tools/parallel_llm.py.
  18. -
  19. Use tests as executable documentation. Start with tests/test_harness.py, then the feature-specific test file for whatever you are changing.
  20. -
-
- -
-

Implementation Deep Dive

-

- This section is for code ownership: the details you need to answer why the code is organized this way, - where behavior lives, and which boundaries are deliberate. It focuses on the current implementation: - runtime ownership, tool execution, structured turn resolution, provider/session boundaries, tracing, - resume, subagents, MCP, search support, and parallel LLM. -

- -
- 1. Code Shape Primer: Pydantic, dataclasses, classes, functions, and protocols -
-

- ThinHarness uses different Python object types for different responsibilities. The short rule is: - serializable setup uses Pydantic, runtime records use dataclasses, long-lived state holders use ordinary - classes, and callable boundaries use functions or protocols. -

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
ShapeUsed forWhyExamples
Pydantic modelCaller-owned configuration and typed argument/output validation.It validates user input, can produce JSON schema, and is reasonable to serialize or inspect.HarnessConfig, SubAgentConfig, tool args such as ReadArgs, structured output types.
DataclassRuntime objects, provider-independent records, and small objects that carry a decision.These are Python-side values, often carrying callables, raw provider data, or mutable run state.ToolSpec, ToolResult, ModelTurn, RunUsage, hook contexts, OutputTurnDecision.
Ordinary classObjects that own state, configuration, setup/cleanup, or a family of methods.The instance owns durable state; individual methods can still be exposed through small dataclasses such as ToolSpec.Harness, FileTools, provider classes, model/session classes, MCPServer, ParallelLlmTool.
Callable/functionExecution hooks and model-callable tool handlers.The harness only needs something it can call after preparing context or arguments.ToolSpec.handler, hook handlers, nested handlers inside ParallelLlmTool.spec().
ProtocolProvider-neutral interfaces.Different providers can implement the same required methods without inheriting from the same base class.Model, ModelSession, tracer-like objects accepted by tracing.
-

- Inheritance is intentionally light. Provider session classes implement the same session protocol, but the core - loop mostly uses composition: Harness owns a model, tool specs, hooks, tracing options, skill registry, - and MCP server list. Built-in tools often use classes as state holders, then hand bound methods to ToolSpec. -

-
-
- -
- 2. Run Loop Ownership: core.py, turns.py, runtime.py, and tool_execution.py -
-

- The run loop is divided by ownership. core.py coordinates the public run, - turns.py owns the turn state machine that drives one run to a terminal result, - runtime.py owns one run's mutable state and the repeated wrapper around each provider call, and - tool_execution.py owns model-requested tool batches and the hook/tracing flow for one tool call. -

- - - - - - - - - - - - - - - - - - - - - - - - -
FileOwnsOwned elsewhere
core.pyHarness construction, public run API, running/closed checks, provider session selection, per-run RequestConstants construction, and run-failure classification.turns.py owns the turn dispatch loop; providers.py owns provider API payload details; tool_execution.py owns one tool call's hook/tracing flow; runtime.py owns repeated provider-call wrapper code.
runtime.pyOne run's mutable state: responses, usage, tool records, limit notices, terminal result/error, stop reason, resume attachment, run_end guard.providers.py owns provider-specific API request formats; tool_execution.py owns individual tool invocation mechanics.
tool_execution.pyBatch execution policy, hook/tracing flow for one tool call, current tool-call context, output parsing, retry-kind capture.output.py owns structured final-answer validation; turns.py chooses whether the next provider call starts, continues with tools, retries output, or stops.
turns.pyClassifies a returned ModelTurn as final, continue with tools, retry via user message, retry via tool output, or unexpected, and dispatches on that decision: it owns the loop that drives a run to a terminal result or approval pause.runtime.py owns retry-budget exhaustion; providers.py owns transport calls; core.py owns exception classification around the single machine call.
-

- The important handoff is turns.advance_until_terminal(...) plus RunContext.advance_model(...). - The turn machine chooses whether to start, resume, send tool outputs, or send a correction, and - supplies the matching provider-session method call with the run's RequestConstants. The run-loop - diagram may label the second case as "continue" for space, but the code path is continue_with_tools(...). - runtime.py wraps that callable with limit checks, usage accounting, - model tracing, notice computation, and output turn resolution. -

-

- This keeps provider API payload knowledge in providers.py while keeping the repeated provider-call wrapper - in one place. -

-
-
- -
- 3. Approval Pause Internals: from pending tool call to resumed batch -
-

- Approval-required tools are a loop primitive rather than a special model output format. A custom - ToolSpec can set requires_approval=True. When a model turn asks for any such - tool, core.py pauses before the batch reaches ToolBatchExecutor, so neither the - approval-required call nor any normal sibling call has side effects yet. -

- - - - - - - - - - - - - - - - - - - - -
PieceRole in approval flow
approvals.pyBuilds and validates the approval_pause envelope, restores usage/history, and validates that host decisions exactly cover approval-required call ids.
RunContext.pause_for_approval()Captures provider resume payload, pending tool batch, usage, responses, tool records, emitted limit-warning keys, and metadata before emitting RunCompletedEvent.
Harness.resume_approvals()Restores the logical run, resumes the provider session, emits ApprovalResumedEvent, validates the approval-required tools still exist, and processes the paused batch.
ToolBatchExecutorRuns approved calls and normal sibling calls through the standard hook, tracing, retry, and output-ordering machinery.
-

- Rejected calls bypass tool hooks and do not execute. They still produce ordered tool outputs for the provider: - a failed ToolResult with error_type="ApprovalRejected". From the model's perspective, - it requested tools and then received tool results on the next turn; it never sees the host pause itself. -

-

- The paused batch counts against usage.tool_calls at pause time and is not counted again during - resume. The post-resume result contains the whole logical run history, not only the second half of the run. - This is why approval envelopes are larger than plain resume state: they carry provider transcript state, prior responses, and - accounting as well as the provider checkpoint. -

-
-
- -
- 4. Tool Execution Internals: from model tool call to provider follow-up -
-

- A provider returns one ModelTurn. That object has text, a tool_calls list, and raw - provider JSON. The tool_calls field is a list[ModelToolCall], where each ModelToolCall - has the provider call id, tool name, and raw JSON argument string. -

-

- When resolve_turn_output() decides the turn should continue with ordinary tools, the harness executes - the whole model-emitted batch and sends one ordered set of ToolOutput values back through - continue_with_tools(...). -

-
    -
  • ToolBatchExecutor chooses serial execution if any requested tool is marked sequential=True; otherwise it can run calls concurrently.
  • -
  • ToolCallExecutor sets the current tool-call context, then runs before_tool_call hooks that may cancel the call.
  • -
  • The executor parses JSON arguments, validates them with Pydantic when a model exists, and calls the async handler directly or the sync handler in a worker thread.
  • -
  • Handler output is normalized into the standard {"ok", "content", "metadata"} tool envelope.
  • -
  • Retry metadata is captured before after_tool_call hooks can rewrite the output text the model sees.
  • -
  • Tracing records one tool span per call, while tool_call_records and provider-facing ToolOutput values preserve the original model order.
  • -
-

- These are the main branches after a model asks for a tool call. They determine whether ThinHarness asks the - model to repair its request, reports a normal tool failure, or continues the provider conversation. -

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
CaseWhat happensWhy it matters
Bad JSON or bad Pydantic argsReturns a failed tool envelope with metadata.retry=true.The model passed arguments in a fixable bad format, so the harness can ask it to retry.
Handler raises ModelRetryReturns a retryable envelope with the handler's hint.Tool code can classify a domain-level mistake as model-repairable.
Handler raises an ordinary exceptionReturns ok=false without metadata.retry=true.The model can still call tools later, but this result does not ask it to repair the same call and does not increment the tool retry budget.
after_tool_call rewrites outputThe model sees the rewritten text, but retry control flow was captured first.Hooks own presentation; the harness owns retry budget accounting.
Any called tool has sequential=TrueThe whole current batch runs serially in model order.Mutating tools avoid race conditions without partitioning the batch into smaller dependency groups.
-
-
- -
- 5. Provider and Session Handoff: reusable models, fresh sessions, common turns -
-

- Provider adapters are deliberately layered. Provider transport classes own auth, base URL, timeout, and HTTP - client setup/cleanup. Model classes own reusable static settings. Session classes own mutable conversation state. -

-

- In plain terms: a Model is the reusable object you pass into Harness(..., model=...). It knows - which provider/model/settings to use, but it should not hold the live conversation transcript. For each - Harness.run(...), it creates a fresh ModelSession; that session is the object the harness - actually talks to during the run. -

- - - - - - - - - - - - - - - - - - - - - - - - -
LayerMutable?Responsibility
Provider transportHTTP client setup/cleanup onlyPost HTTP requests, wrap HTTP/transport errors, close owned clients.
ModelNo provider transcriptThe reusable object passed to Harness. It creates a fresh session with new_session(), or a resumed session with resume_session(...) when resume is supported.
ModelSessionYesThe per-run conversation object. The turn machine calls its start(...), continue_with_tools(...), and continue_with_user_text(...) methods with per-run RequestConstants, plus dump_state().
ModelTurnNoCommon result object: final text extracted from the response, requested tool calls, and raw provider JSON.
-

- The core loop should not know OpenAI Responses, Anthropic Messages, or OpenRouter Chat Completions API formats. - It receives a ModelTurn and applies the same output resolution, tool execution, tracing, hook, limit, - and retry logic regardless of provider. -

-

- Provider-specific translation stays in providers.py: common function tools become Anthropic - input_schema tools or Chat Completions function tools; native structured output requests become - provider-specific API fields; notice text is rendered into each provider's input format. -

-
-
- -
- 6. Structured Output: one resolver, several delivery strategies -
-

- Structured output is not bolted onto every provider separately. The harness builds an OutputSchema - from the caller's output spec, then providers translate the provider-native request details when native mode is used. - resolve_turn_output() decides what one returned ModelTurn means. -

- - - - - - - - - - - - - - - - - - - - - - - - -
ModeHow the model is guidedHow the final result is recognized
textNo schema payload and no harness-created tool.Final assistant text populates both text and output.
nativeThe provider API is asked directly for JSON-schema output.A final native-output turn has no ordinary tool calls. Earlier turns may still request ordinary tools; final assistant text is parsed and validated through Pydantic.
toolA harness-created final_result function tool is exposed to the provider.Exactly one final_result call, with no sibling tool calls in that same turn, completes the run and builds the final result.
promptedThe harness appends JSON-schema instructions to the prompt/instructions instead of using provider-native schema output or final_result.A final prompted-output turn has no ordinary tool calls. Earlier turns may still request ordinary tools; final assistant text is parsed and validated.
-

- final_result is harness-created, not a normal user tool. It is reserved only when structured tool mode is active, - is not exposed in self.tools, does not fire tool hooks, does not count as usage.tool_calls, - and makes the exit non-resumable because the provider transcript would contain an unanswered synthetic tool call. - Native and prompted structured-output exits can still be resumable when the provider session can dump clean resume state. -

-

- Non-object output schemas such as lists are wrapped under a single value argument for tool mode because - function tool arguments must be JSON objects. Validation uses Pydantic's public TypeAdapter API and - local schema cleanup. The local implementation is intentionally narrower than Pydantic AI: it does not stream - partial structured objects, treat a Python function signature as the output schema, or run custom output validator hooks. -

-

- Invalid structured output creates corrective model requests until output_retries is exhausted. - Retry-budget exhaustion is handled by the caller of the resolver: Harness.run() turns it into a run failure, while - ParallelLlmTool turns it into a per-entry failure. -

-
-
- -
- 7. State, limits, retry budgets, and resume -
-

- Harness is reusable setup; RunContext is one invocation's state. That distinction is - the main reason repeated runs on the same harness do not leak responses, usage, limit warnings, or resume bookkeeping. -

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
ConceptWhere it livesImportant detail
Model request limitRunContext and RunUsage.model_requestsCounts provider calls, including corrective structured-output requests after the limit check allows them.
Tool call limitRunContext.check_tool_limit and RunUsage.tool_callsCounts model-requested ordinary tool calls before execution, so hook-blocked calls still count.
Cancelled tool callsRunUsage.cancelled_tool_callsTracked separately after before_tool_call hooks cancel calls; cancellation does not erase the requested-call count.
Tool retry budgetRunUsage.tool_retries, a dict keyed by tool name and updated by RunContext.check_tool_retry_limits(...)Two calls to the same tool share the same retry counter. This is coarse by design.
Output retry budgetBudget: HarnessConfig.output_retries. Current count: RunUsage.output_retries.Counts corrective model requests after invalid structured output, not total validation attempts. RunContext.retry_or_fail() checks the budget before the corrective request is made.
Near-limit notices_compute_limit_notices(...) returns provider-facing ModelNotice values; RunContext.emitted_limit_warnings records which warning thresholds already went out.Computed from the current RunUsage.model_requests and RunUsage.tool_calls before the next provider request. A warning for the same budget threshold is sent once per run, not repeated on every later provider call.
Resume stateProvider-agnostic transcript state copied into HarnessResult.resume_state while building the final result.Built-in providers emit kind="transcript", version=3, origin diagnostics, and neutral user/assistant/tool entries. Callers can store and pass it back, but should not edit or construct it.
Approval pause stateHarness-level approval_pause envelope copied into HarnessResult.resume_state when stop_reason="approval_required".Wraps provider transcript state plus pending batch, run history, usage, emitted limit-warning keys, and metadata. It must be resumed with resume_approvals(), not resume_from.
-

- resume_from starts a new turn from a previous clean result. It is not a failed-request retry, - interrupted-tool continuation, or transcript repair mechanism. Errors, cancellation, limit exits, tool retry - exhaustion, output validation failure, unexpected model behavior, and tool-mode final_result exits - intentionally produce no checkpoint. -

-

- Resume also carries model reasoning. Each built-in session keeps the provider's native reasoning parts in the - neutral transcript, so resuming on the same provider replays them verbatim — Anthropic signed thinking - blocks, OpenAI encrypted_content, OpenRouter reasoning_details. An opaque blob cannot - be replayed to a different provider, so cross-provider resume degrades every reasoning part to a leading - <thinking>-tagged text block and drops the blob. Native re-emit also requires the resuming - request to be able to accept the block: -

- - - - - - - - - - - - - - - - - - - -
ProviderNative reasoning in resume stateRe-emits natively only when
OpenAI Responsesencrypted_content, captured via include=["reasoning.encrypted_content"] on reasoning-capable modelsthe resuming model is reasoning-capable; otherwise the text fallback is used
Anthropic Messagessigned thinking / redacted_thinking blocksextended thinking is enabled on the resuming run; otherwise the text fallback is used
OpenRouter chat completionsreasoning_detailsresuming on OpenRouter — no additional capability gate
-

- Because resume_state can therefore hold encrypted reasoning blobs and signed thinking, treat it as - sensitive, like the local traces it mirrors. -

-
-
- -
- 8. Extras Internals: JSONL search, skills, subagents, MCP, and parallel LLM -
-

- Extras are specialized capabilities that an application can ignore unless it uses that feature. They still - enter the run through existing ToolSpec, Harness, and provider-session boundaries. -

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
ExtraWhere responsibility changes handsBoundary / behavior
JSONL searchFileTools owns the JsonlSearch instance and exposes its spec with the filesystem built-ins.search_support.py holds shared ripgrep parsing, glob validation, containment filtering, and search-root helpers used by both text search and JSONL search; jsonl.py owns structured field projection, range filters, and field snippet rendering.
SubagentsThe subagent framework tool builds a child Harness and returns the child result as a tool result.Child runs start fresh, recursion is structurally disabled, and inherited custom tools are live ToolSpec objects.
MCPConfigured server objects connect lazily and discover tools into live ToolSpec objects.Subagents do not inherit parent-discovered MCP tools as ordinary custom ToolSpec objects. They opt into MCP through inherited or explicit server config, then discover tools in the child harness lifecycle.
SkillsSkillRegistry exposes skill_read and skill_run when skills are configured.Skills add prompt summaries plus skill_read and skill_run; they do not create one tool per skill. Script runners are extension-based.
Parallel LLMA normal ToolSpec wrapper for independent one-shot model calls.The built-in is configured by application code and text-only; custom ParallelLlmTool can opt into structured output.
-
-
-
- -
-

Part of the ThinHarness documentation site · last updated 2026-06-05. The source focus is current project runtime and docs; generated caches, build outputs, and vendor reference checkouts are treated as non-runtime material.

-
-
- - - - diff --git a/docs/site/index.html b/docs/site/index.html deleted file mode 100644 index c32528b..0000000 --- a/docs/site/index.html +++ /dev/null @@ -1,88 +0,0 @@ - - - - - -ThinHarness — a minimal, opinionated agent harness - - - - - - - -
-
-
-
v0.4 · pre-1.0 · MIT licensed ·
-

A minimal, opinionated agent harness.

-

Build agents without adopting a stack you don’t need. Focused scope, straightforward code, easy to fork.

- -
-
-
-
install.sh
-
copy$ uv add thinharness
# or: pip install thinharness
# requires python 3.11+

resolved · 24 files · 8,035 LOC
-
-
-
- -
-
// 01

Opinions

the reason it stays small
-
-
purpose_built

Purpose-built agents

ThinHarness is for bounded agent loops inside software you control, not open-ended interactive assistants.

-
no_bash

No bash by default

Bash stays out of the default tools, with an opt-in BashTool only for prototyping before typed tools.

-
search

Search is a top priority

Ripgrep exposed as compact grouped results, tuned for documents and business workflows — plus a custom JSONL search tool for structured corpuses.

-
parallel_llm

Parallel LLM calls, built in

Fan out from inside the harness when a workflow needs efficient parallel processing or majority vote for reliability.

-
no_compaction

No compaction

Compaction makes sense for sprawling coding sessions. For business agents the fix is smarter task decomposition and context management

-
no_deployment

No deployment layer

Serving, auth, durable jobs, and session storage stay yours. ThinHarness owns the agent loop, not the production stack around it.

-
-
- -
-
// 02

Explore

- -
-
- -
- -
- - - - diff --git a/examples/mcp_plugin.py b/examples/mcp_plugin.py new file mode 100644 index 0000000..80969d3 --- /dev/null +++ b/examples/mcp_plugin.py @@ -0,0 +1,29 @@ +"""Connect one ordered group of MCP servers through MCPPlugin.""" + +import asyncio + +from thinharness import Harness, HarnessConfig, MCPPlugin, MCPServerStdio + + +async def main() -> None: + """Run an agent with tools discovered from a local MCP server.""" + async with Harness( + HarnessConfig(root=".", model="openai:gpt-5.5"), + plugins=[ + MCPPlugin( + servers=[ + MCPServerStdio( + "python", + ["server.py"], + tool_prefix="docs", + ) + ] + ) + ], + ) as harness: + result = await harness.run("Use the docs tools to answer the question.") + print(result.text) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/web_research_report/agent.py b/examples/web_research_report/agent.py index 0b4767e..da20502 100644 --- a/examples/web_research_report/agent.py +++ b/examples/web_research_report/agent.py @@ -16,7 +16,19 @@ import httpx from pydantic import BaseModel, ConfigDict, Field -from thinharness import Harness, HarnessConfig, Hook, ParallelLlmTool, PathPolicy, PathValidationError, SubAgentConfig, ToolResult, ToolSpec +from thinharness import ( + FilesystemPlugin, + Harness, + HarnessConfig, + Hook, + ParallelLlmTool, + PathPolicy, + PathValidationError, + SubAgentConfig, + SubagentsPlugin, + ToolResult, + ToolSpec, +) REPO_ROOT = Path(__file__).resolve().parents[2] EXAMPLE_ROOT = Path(__file__).resolve().parent @@ -487,34 +499,38 @@ def build_harness(root: Path, *, model: str = DEFAULT_MODEL) -> Harness: root=root, model=model, system_prompt=SYSTEM_PROMPT, - builtin_tools=["read", "write", "edit", "search", "list", "glob", "jsonl_search", "subagent"], output_type=ReportReceipt, output_mode=output_mode, output_retries=2, tool_retries=2, max_model_requests=64, max_tool_calls=96, - read_paths=["outputs"], - write_paths=["outputs"], - max_read_chars=80_000, - max_tool_chars=80_000, tool_execution="sequential", request_timeout=240, temperature=0, extra_body=_model_extra_body(model), - subagents=[ + ), + plugins=[ + FilesystemPlugin( + tools=["read", "write", "edit", "search", "list", "glob", "jsonl_search"], + read_paths=["outputs"], + write_paths=["outputs"], + max_read_chars=80_000, + max_tool_chars=80_000, + ), + SubagentsPlugin(agents=[ SubAgentConfig( name="citation_critic", description="Citation and evidence critic for saved draft reports.", system_prompt=CITATION_CRITIC_PROMPT, - builtin_tools=["read", "search", "list", "glob"], + plugins=[FilesystemPlugin(tools=["read", "search", "list", "glob"], read_paths=["outputs"])], max_model_requests=10, max_tool_calls=20, output_retries=1, tool_retries=1, ) - ], - ), + ]), + ], tools=[*exa_tools.specs(), parallel_tool], hooks=[Hook("after_tool_call", _source_audit_hook)], ) diff --git a/pyproject.toml b/pyproject.toml index b54c217..8a4dfab 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,8 +4,8 @@ build-backend = "setuptools.build_meta" [project] name = "thinharness" -version = "0.6.0" -description = "Minimal filesystem agent harness with provider-backed Responses-like models." +version = "0.7.0" +description = "Minimal plugin-based agent harness with provider-backed Responses-like models." readme = "README.md" requires-python = ">=3.11" license = "MIT" @@ -92,6 +92,3 @@ dev = [ "pytest-cov>=7", "ruff>=0.14", ] -docs = [ - "markdown>=3.6", -] diff --git a/scripts/build_site.py b/scripts/build_site.py deleted file mode 100644 index 44715ff..0000000 --- a/scripts/build_site.py +++ /dev/null @@ -1,420 +0,0 @@ -from __future__ import annotations - -import argparse -import difflib -import html -import re -from html.parser import HTMLParser -from pathlib import Path - -REPO_ROOT = Path(__file__).resolve().parents[1] -README = REPO_ROOT / "README.md" -SITE_DIR = REPO_ROOT / "docs" / "site" -ABOUT = SITE_DIR / "about" / "index.html" -GITHUB_ROOT = "https://github.com/ryanbbrown/thinharness" - - -def section(markdown: str, title: str) -> str: - pattern = re.compile(rf"^## {re.escape(title)}\n(?P.*?)(?=^## |\Z)", re.M | re.S) - match = pattern.search(markdown) - if not match: - raise ValueError(f"README section not found: {title}") - return match.group("body").strip() - - -def inline_markdown(text: str) -> str: - placeholders: list[str] = [] - - def hold(value: str) -> str: - placeholders.append(value) - return f"\0{len(placeholders) - 1}\0" - - text = re.sub(r"`([^`]+)`", lambda m: hold(f"{html.escape(m.group(1))}"), text) - text = re.sub(r"\[([^\]]+)\]\([^)]+\)", lambda m: m.group(1), text) - escaped = html.escape(text, quote=False) - escaped = re.sub(r"\*\*([^*]+)\*\*", r"\1", escaped) - escaped = re.sub(r"\*([^*]+)\*", r"\1", escaped) - for index, value in enumerate(placeholders): - escaped = escaped.replace(f"\0{index}\0", value) - return escaped - - -def paragraphs(markdown: str) -> list[str]: - return [block.replace("\n", " ") for block in markdown.split("\n\n") if block.strip()] - - -def slug_for_opinion(title: str) -> str: - known_tags = { - "Purpose-built agents, not universal agents": "purpose_built", - "No bash by default": "no_bash", - "Search is a top priority": "search", - "Parallel LLM calls, built in": "parallel_llm", - "Three providers, no matrix": "providers", - "No compaction": "no_compaction", - "No deployment layer": "no_deployment", - } - if title in known_tags: - return known_tags[title] - return re.sub(r"[^a-z0-9]+", "_", title.lower()).strip("_") or "opinion" - - -def opinions_from_readme(markdown: str) -> list[tuple[str, str, str]]: - body = section(markdown, "Opinions") - result: list[tuple[str, str, str]] = [] - for match in re.finditer(r"\*\*([^*]+?)\.\*\* ([\s\S]*?)(?=\n\n\*\*|\Z)", body): - title = match.group(1) - text = match.group(2).strip().replace("\n", " ") - if title == "Three providers, no matrix" and not text.endswith("."): - text += "." - result.append((slug_for_opinion(title), title, inline_markdown(text))) - return result - - -def features_from_readme(markdown: str) -> list[tuple[str, str]]: - body = section(markdown, "Features") - result: list[tuple[str, str]] = [] - for match in re.finditer(r"^- \*\*([^*]+):\*\* (.+)$", body, re.M): - title, text = match.groups() - if title == "Limit notices": - text = text.replace( - "Notices are harness-owned model input, not hooks or callbacks; parent and child runs compute them from their own local budgets.", - "Harness-owned model input — parent and child runs compute them from their own local budgets.", - ) - result.append((title, sentence_case(inline_markdown(text)))) - return result - - -def sentence_case(text: str) -> str: - if not text: - return text - return text[0].upper() + text[1:] - - -class TableParser(HTMLParser): - def __init__(self) -> None: - super().__init__(convert_charrefs=True) - self.rows: list[list[dict[str, str]]] = [] - self._row: list[dict[str, str]] | None = None - self._cell: dict[str, str] | None = None - self._text: list[str] = [] - self._sup: list[str] = [] - self._in_sup = False - - def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: - if tag == "tr": - self._row = [] - elif tag in {"td", "th"} and self._row is not None: - self._cell = {"text": "", "img": "", "sup": ""} - self._text = [] - self._sup = [] - self._in_sup = False - elif tag == "img" and self._cell is not None: - attrs_dict = dict(attrs) - self._cell["img"] = attrs_dict.get("src") or "" - elif tag == "sup" and self._cell is not None: - self._in_sup = True - elif tag == "br" and self._cell is not None: - self._text.append(" ") - - def handle_endtag(self, tag: str) -> None: - if tag == "sup" and self._cell is not None: - self._in_sup = False - elif tag in {"td", "th"} and self._cell is not None and self._row is not None: - self._cell["text"] = normalize_table_text("".join(self._text)) - self._cell["sup"] = normalize_table_text("".join(self._sup)) - self._row.append(self._cell) - self._cell = None - elif tag == "tr" and self._row is not None: - if self._row: - self.rows.append(self._row) - self._row = None - - def handle_data(self, data: str) -> None: - if self._cell is None: - return - if self._in_sup: - self._sup.append(data) - else: - self._text.append(data) - - -def normalize_table_text(text: str) -> str: - return re.sub(r"\s+", " ", text.replace("\xa0", " ")).strip() - - -def readme_table_rows(markdown: str) -> list[list[dict[str, str]]]: - table_match = re.search(r".*?
", markdown, re.S) - if not table_match: - raise ValueError("README comparison table not found") - parser = TableParser() - parser.feed(table_match.group(0)) - return parser.rows - - -def mark(value: str) -> str: - return { - "✅": '', - "❌": '', - "⚠️": '', - }[value] - - -def library_cell(cell: dict[str, str], asset_prefix: str = "assets/") -> str: - name = cell["text"] - superscript = f"{html.escape(cell['sup'])}" if cell.get("sup") else "" - display_name = html.escape(name) - if name == "ThinHarness": - return f'
ThinHarness
' - if name == "Agno": - return f'
Agno
' - img = cell["img"] - img_src = img if re.match(r"https?://", img) else f"{asset_prefix}{img}" - return f'
{display_name}{superscript}
' - - -def comparison_table(markdown: str, asset_prefix: str = "assets/") -> str: - rows = readme_table_rows(markdown) - body_rows = [] - for row in rows[1:]: - library = row[0] - loc = row[1] - loc_sup = f'{html.escape(loc["sup"])}' if loc.get("sup") else "" - css = ' class="me"' if library["text"] == "ThinHarness" else "" - marks = "".join(f"{mark(cell['text'])}" for cell in row[2:]) - body_rows.append( - f""" - {library_cell(library, asset_prefix)} - {html.escape(loc["text"])}{loc_sup} - {marks} - """ - ) - return "\n".join(body_rows) - - -def code_highlight(code: str) -> str: - escaped = html.escape(code) - escaped = re.sub(r"\b(import|from|async def|async with|as|await)\b", r'\1', escaped) - escaped = re.sub(r'("[^&]+?")', r'\1', escaped) - return escaped.replace(""", '"') - - -def fenced_code(markdown: str, language: str) -> str: - match = re.search(rf"```{language}\n(.*?)\n```", markdown, re.S) - if not match: - raise ValueError(f"{language} code block not found") - return match.group(1) - - -def render_about(markdown: str) -> str: - asset_prefix = "../assets/" - why = paragraphs(section(markdown, "Why this exists").split("