feat(tinyagents): Phase 0/1 port cutover — pin v1.7.1, crate-back SchemaCleanr / model-context / observed stream (#4539)

This commit is contained in:
Steven Enamakel
2026-07-05 03:01:28 -07:00
committed by GitHub
parent 6ad3b990ed
commit ebc81b78de
8 changed files with 371 additions and 675 deletions
+103
View File
@@ -0,0 +1,103 @@
# TinyAgents Drift Ledger (Phase 0)
**Purpose.** The TinyAgents migration spans `inference`, `tools`, and
`agent_orchestration`, while the OpenHuman host will keep evolving. This ledger
records the baseline used for the port plan and tracks which host-side drift must
be upstreamed, retained, or deleted before each phase cuts over.
- **DRIFT -> tinyagents PR** - generic engine behavior absent from the crate; port
upstream before deleting the host copy.
- **HOST-OWNED** - OpenHuman product policy, RPC, config, credentials, UI, local
runtime, or integration glue. No upstream action.
- **CONSOLIDATE / DELETE** - duplicate host implementation already covered by
TinyAgents primitives; delete only after the seam proves the crate-backed path.
- **CLOSED** - resolved by a submodule/version bump or a completed cutover.
> **Gate rule:** no phase deletes host code until every open row for that phase
> is either upstreamed and bumped, reclassified host-owned, or covered by a
> crate-backed seam test.
## Anchors
| Thing | Value |
| --- | --- |
| Host repo | `tinyhumansai/openhuman` |
| Host branch | `docs/tinyagents-port-plan` |
| Host audit base | `42ce5c0e9` (`origin/main`, 2026-07-04) |
| Plan commit | `24f200e49` (`docs: TinyAgents port plan`) |
| TinyAgents submodule | `vendor/tinyagents` -> `tinyhumansai/tinyagents` |
| Phase 0 target | `v1.6.0` / `e72036d847b589044aa9a4add1b34544b92a293d` |
| Current host pin | `v1.7.1` / `3e81e493` |
## Baseline Snapshot
Recorded from `docs/tinyagents-port-plan` after the Phase 0 version alignment
work started. Counts include Rust files only.
| Host module | Rust files | LOC | Test fns | Plan disposition |
| --- | ---: | ---: | ---: | --- |
| `src/openhuman/inference/` | 116 | 53,023 | 1,101 | Provider consolidation and small generic ports |
| `src/openhuman/tools/` | 94 | 38,553 | 877 | Tool model reconciliation, then builtin family ports |
| `src/openhuman/agent_orchestration/` | 64 | 25,769 | 262 | Sub-agent lifecycle consolidation onto TinyAgents graph/orchestration |
| `src/openhuman/tinyagents/` | 25 | 15,219 | 101 | Host seam; shrinks but remains OpenHuman-owned |
## Phase 0 Drift Rows
| # | Area | Status | Evidence / action |
| --- | --- | --- | --- |
| P0-1 | Version skew: host required `tinyagents = 1.5.0` while the intended engine baseline was `v1.6.0` | **CLOSED** | Phase 0 first aligned the host to `v1.6.0`; the current Phase 1 host pin is now `v1.7.1` in root `Cargo.toml`, both lockfiles, and `vendor/tinyagents` (`3e81e493`). |
| P0-2 | `ToolCompleted` outcome was reconstructed through OpenHuman's `ToolFailureMap` side channel | **CLOSED** | `src/openhuman/tinyagents/observability.rs` consumes TinyAgents 1.6 `duration_ms`, `output_bytes`, and `error`; `ToolFailureMap` now only preserves OpenHuman's richer classified failure and legacy fallback fields. |
| P0-3 | TinyAgents 1.6 event constructor shape changed for local observability tests | **CLOSED** | Local constructors in `src/openhuman/tinyagents/observability.rs` include `ModelCompleted.started_at_ms` and the expanded `ToolCompleted` fields. |
| P0-4 | `invoke_stream` adoption in `src/openhuman/tinyagents/mod.rs` | **CLOSED** | TinyAgents PR [tinyagents#21](https://github.com/tinyhumansai/tinyagents/pull/21) shipped context-preserving `invoke_stream_in_context` in `v1.7.0`; follow-up PR [tinyagents#28](https://github.com/tinyhumansai/tinyagents/pull/28) made that stream `Send` and shipped in `v1.7.1`. `OpenHumanTinyAgentModel::invoke` now drives `invoke_stream_in_context` when progress streaming is enabled, consuming terminal `AgentStreamItem`s while the existing `EventSink` bridge continues to mirror progress. Local validation for #28 in the submodule: `cargo fmt --check`; `timeout 180s cargo clippy --all-targets -- -D warnings`; `timeout 120s cargo test invoke_stream_in_context_stream_is_send`; `timeout 120s cargo test invoke_stream_in_context_unsubscribes_channel_listener`. GitHub release run `28729225952` passed TinyAgents format, clippy, tests, package, tag, and crates.io publish for `v1.7.1`. |
| P0-5 | SHA-256 prompt fingerprint / prompt-cache drift guard | **CLOSED** | `src/openhuman/tinyagents/middleware.rs` now stamps `PromptCacheSegmentMiddleware` segment ids and `ModelRequest::prompt_fingerprint` with SHA-256 over canonical JSON. Tool-cache identity includes the full serialized `ToolSchema` list, not just tool names, matching TinyAgents 1.6 `PromptBuilder::fingerprint` expectations. Added `prompt_cache_segments_fingerprint_full_tool_schema` as the local regression guard. |
| P0-6 | Idempotent redaction middleware vs `journal.rs` double-redaction | **CLOSED** | Audit found no OpenHuman install of TinyAgents `RedactionMiddleware`. Model-facing tool output is scrubbed once by `CredentialScrubMiddleware`; durable event persistence is separately wrapped by `journal.rs` `RedactingSink` over `openhuman_redaction_secrets()`. These protect different surfaces, so there is no crate/host double-redaction seam to collapse in Phase 0. |
## Phase 1 Drift Rows
| # | Area | Status | Evidence / action |
| --- | --- | --- | --- |
| P1-1 | `SchemaCleanr` provider schema normalization | **CLOSED** | TinyAgents PR [tinyagents#20](https://github.com/tinyhumansai/tinyagents/pull/20) shipped in `v1.7.0`. Host `src/openhuman/tools/schema.rs` now re-exports `tinyagents::harness::tool::{CleaningStrategy, SchemaCleanr, GEMINI_UNSUPPORTED_KEYWORDS}`, keeping the old OpenHuman import path stable while deleting the in-tree implementation. Local TinyAgents validation before merge: `cargo fmt --check`; `timeout 180s cargo clippy --all-targets -- -D warnings`; `timeout 120s cargo test schema_`. |
| P1-2 | `current_time` / `resolve_time` builtin tool pilot | **RELEASED / HOST WRAPPER RETAINED** | TinyAgents PR [tinyagents#22](https://github.com/tinyhumansai/tinyagents/pull/22) shipped in `v1.7.0` with optional `tools` feature exports. Host wrappers remain in place until Phase 2 reconciles `ToolResult`, permission, access, and timeout semantics enough to adopt crate builtin tools. Local TinyAgents validation before merge: `cargo fmt --check`; `timeout 240s cargo clippy --features tools --all-targets -- -D warnings`; `timeout 180s cargo test --features tools time_`. |
| P1-3 | `model_context.rs` generic context-window patterns | **CLOSED** | TinyAgents PR [tinyagents#23](https://github.com/tinyhumansai/tinyagents/pull/23) shipped in `v1.7.0`. Host `context_window_for_model` now checks OpenHuman tier aliases and the cost catalog first, then delegates generic raw-model fallback to `tinyagents::harness::model::context_window_for_model_id`. Local TinyAgents validation before merge: `cargo fmt --check`; `timeout 180s cargo clippy --all-targets -- -D warnings`; `timeout 120s cargo test context_window_patterns_cover_common_provider_families`; `timeout 120s cargo test o1_o3_context_patterns_require_segment_boundaries`. |
| P1-4 | `error_classify.rs` generic provider failure classifiers | **RELEASED / HOST CALL-SITE PENDING** | TinyAgents PR [tinyagents#24](https://github.com/tinyhumansai/tinyagents/pull/24) shipped in `v1.7.0` with `harness::retry::{ProviderFailureClass, classify_provider_failure, classify_provider_error, structured_http_status, parse_retry_after_ms}` and shared OpenAI retryability classification. Host retry/failure call-site swaps remain pending because OpenHuman-specific session, billing-envelope, and backend phrase rules stay host-side. Local TinyAgents validation before merge: `cargo fmt --check`; `timeout 180s cargo clippy --all-targets -- -D warnings`; `timeout 120s cargo test provider_failure`; `timeout 120s cargo test structured_http_status`; `timeout 120s cargo test retry_after_parser_accepts_integer_float_and_space_separators`; `timeout 120s cargo test classify_provider_error_reads_structured_error_fields`. |
| P1-5 | First-class reasoning channel host cutover | **CLOSED** | TinyAgents `v1.6.0` already carries typed reasoning via `ContentBlock::Thinking`, `ContentBlock::RedactedThinking`, `MessageDelta::reasoning`, and stream reconstruction that preserves thinking blocks. OpenHuman now writes new non-streaming `reasoning_content` into `ContentBlock::Thinking` instead of `ProviderExtension`, while still reading legacy `ProviderExtension` reasoning from persisted transcripts and continuing to echo `ChatMessage::extra_metadata` for provider replay. Local validation: `cargo fmt --check` passed; two targeted `cargo test --lib --manifest-path Cargo.toml ...` attempts for the new conversion tests timed out during host test compilation before executing, so runtime verification is deferred to GitHub runners. |
| P1-6 | Git-worktree `WorkspaceIsolation` provider | **RELEASED / HOST WRAPPER RETAINED** | TinyAgents PR [tinyagents#25](https://github.com/tinyhumansai/tinyagents/pull/25) shipped in `v1.7.0`. OpenHuman's wrapper remains for global event-bus emissions, `OutsideWorkspace`, and host policy mapping; adapter deletion waits for a focused wrapper-thinning pass. Local TinyAgents validation before merge: `cargo fmt --check`; `timeout 180s cargo clippy --all-targets -- -D warnings`; targeted worktree tests for create/list/status/diff/remove plus overlap and sanitize filters. |
| P1-7 | Tool display metadata and timeout semantics | **RELEASED / HOST TRAIT RETAINED** | TinyAgents PR [tinyagents#26](https://github.com/tinyhumansai/tinyagents/pull/26) shipped in `v1.7.0`. Host `ToolPolicy` projection now fills the new `ToolRuntime.timeout` field, but OpenHuman's `Tool` trait still owns richer legacy display/timeout semantics until the Phase 2 tool model reconciliation. Local TinyAgents validation before merge: `cargo fmt --check`; `timeout 180s cargo clippy --all-targets -- -D warnings`; `timeout 120s cargo test display_`; `timeout 120s cargo test tool_policy_deserializes_without_display_metadata`; `timeout 120s cargo test timeout_policy_uses_richer_timeout_semantics`. |
## Host Validation Notes
Local host validation is intentionally bounded because full suites are deferred
to GitHub runners. `cargo fmt --check` passed after the v1.7.1 host changes.
Targeted `timeout 240s cargo test --lib --manifest-path Cargo.toml schema_`
and `timeout 240s cargo test --lib --manifest-path Cargo.toml context_window`
timed out during host compilation before executing the filtered tests. A bounded
`timeout 240s cargo check --lib --manifest-path Cargo.toml` first exposed the
non-`Send` stream cutover issue and the new `ToolRuntime.timeout` field; after
filling `ToolRuntime.timeout`, TinyAgents `v1.7.1` closed the stream `Send`
blocker and the host re-applied the `invoke_stream_in_context` cutover. A fresh
bounded `timeout 240s cargo check --lib --manifest-path Cargo.toml` then timed
out before completion with warning output only and no post-cutover compiler
error emitted before the cap.
## Phase Gates
| Phase | Gate rows | Status |
| --- | --- | --- |
| Phase 0 - version alignment | P0-1, P0-2, P0-3, P0-4, P0-5, P0-6 | **CLOSED** |
| Phase 1 - quick upstream ports | SchemaCleanr, error classification, model context, reasoning channel, worktree isolation, display metadata, time tools | **PARTIAL HOST CUTOVER** |
| Phase 2 - tool model and builtin families | ToolResult structure, permission model, ToolAccess, edit tracking, filesystem/network/time tools | **NOT STARTED** |
| Phase 3 - provider consolidation | OpenAI-compatible provider cutover, retry ownership, backend envelope split | **NOT STARTED** |
| Phase 4 - orchestration consolidation | TaskStore/SteeringRegistry lifecycle, status vocabulary, session durability | **NOT STARTED** |
| Phase 5 - workflow/team generic slices | Validation/scheduling slice evaluation | **NOT STARTED** |
| Phase 6 - cleanup and docs | Transitional shim deletion and architecture docs | **NOT STARTED** |
## Closing Procedure
1. For a **DRIFT -> tinyagents PR** row, branch inside `vendor/tinyagents`, port
the generic change with crate-native tests, merge/release upstream, then bump
the host submodule and version pin together.
2. For a **HOST-OWNED** row, document the boundary and keep the logic in
OpenHuman behind the seam.
3. For a **CONSOLIDATE / DELETE** row, add or update the seam proof first, cut the
live path to TinyAgents, then delete the duplicate host implementation.
4. Update this ledger in the same host PR that closes or reclassifies a row.
+30 -21
View File
@@ -1,6 +1,8 @@
# TinyAgents Port — Plan & Audit (inference / tools / agent_orchestration)
**Status:** draft plan — no code changes yet.
**Status:** Phase 1 partial host cutover — TinyAgents `v1.7.1` is tagged and
the host now pins it; `SchemaCleanr`, the generic model-context table, and the
context-preserving `invoke_stream` path are crate-backed.
**Anchor precedent:** the TinyAgents harness migration (#4249 / #4399 / #4473) and the TinyCortex memory migration plan (`docs/tinycortex-memory-migration-plan.md`).
**Target:** move the genuinely framework-shaped parts of `src/openhuman/inference/`, `src/openhuman/tools/`, and `src/openhuman/agent_orchestration/` down into the `tinyagents` crate (vendored git submodule at **`vendor/tinyagents`**, `https://github.com/tinyhumansai/tinyagents`), and delete the in-tree duplicates in favor of crate primitives.
@@ -17,7 +19,7 @@
| `src/openhuman/agent_orchestration/` | ~25,800 | Product control plane over sub-agents: in-memory session, detached-run registry, workflow runs, agent teams, command center, worktree isolation, RPC/tools surface |
| `src/openhuman/tinyagents/` (the seam) | ~15,200 (25 files) | Adapters implementing tinyagents traits over openhuman services — where all ported code plugs back in |
**TinyAgents** (sibling repo, v1.6.0, ~71.5k LOC, edition 2024, GPL-3.0-only, published to crates.io) already provides: `harness/` (`ChatModel`, `Tool<State>`, agent loop, middleware, retry/fallback, usage/cost, cache, embeddings + vector store, summarization, steering, `SubAgent`/`SubAgentSession`/`SubAgentTool`, observability journals, `WorkspaceIsolation`, testkit), `graph/` (durable typed graphs, checkpointers incl. SQLite, recursion policy, `map_reduce`, `orchestration::TaskStore`/`SteeringRegistry`, topology export), `registry/`, and the `.rag`/`.ragsh` languages. ~30 public traits are the extension points.
**TinyAgents** (sibling repo, v1.7.1, edition 2024, GPL-3.0-only, published to crates.io) already provides: `harness/` (`ChatModel`, `Tool<State>`, agent loop, middleware, retry/fallback, usage/cost, cache, embeddings + vector store, summarization, steering, `SubAgent`/`SubAgentSession`/`SubAgentTool`, observability journals, `WorkspaceIsolation`, testkit), `graph/` (durable typed graphs, checkpointers incl. SQLite, recursion policy, `map_reduce`, `orchestration::TaskStore`/`SteeringRegistry`, topology export), `registry/`, and the `.rag`/`.ragsh` languages. ~30 public traits are the extension points.
**License:** both repos are GPL-3.0 — moving code down is license-clean. But tinyagents is publicly redistributed on crates.io, so only genuinely generic code moves (no product policy, backend phrases as load-bearing API, or keys).
@@ -40,13 +42,20 @@ So the correct plan has **three motions**, not one:
2. **True ports (net-new upstream)** — a short list of clean, generic pieces tinyagents lacks (§2).
3. **Stays in openhuman** — RPC/controllers, config/credentials/policy glue, local runtime + voice, product tools, UI surfaces (§3).
### 0.3 Version drift (must be resolved first)
### 0.3 Version status
- Sibling repo / upstream: **v1.6.0** (`e72036d`).
- Host `Cargo.toml`: requires `tinyagents = { version = "1.5.0", features = ["sqlite"] }`, patched to `vendor/tinyagents`.
- Submodule pin: `357bcc8` = **v1.5.0-11-g357bcc8** — 11 commits past the 1.5.0 tag, but **behind 1.6.0**. A plain `git submodule update` matches neither published version; pin explicitly.
**Phase 0 closed:** the host branch first aligned `vendor/tinyagents` to
`v1.6.0` and closed the baseline seam fallout: `ToolCompleted` outcome
projection, SHA-256 prompt-cache fingerprinting, and the redaction-layer audit.
Missing between the pin and 1.6.0, all of which the seam actively wants: `invoke_stream` + sub-agent delta propagation (tinyagents#17), tool outcome on `ToolCompleted` (#18), REPL host-embedding cancellation (#19), concurrent independent tool calls per turn, `DurabilityMode::Async` checkpoint writes, SHA-256 prompt fingerprint (affects the seam's KV-cache drift guard), idempotent `RedactionMiddleware` (affects `journal.rs`), `InMemoryVectorStore` dim-validation/top-k, `Checkpointer::get_thread`/`copy_thread`.
**Phase 1 partial cutover:** TinyAgents `v1.7.1` is tagged at
`3e81e493`, and the host now pins both lockfiles plus `vendor/tinyagents` to
that release. `SchemaCleanr` is crate-backed through the stable OpenHuman
import path; `model_context.rs` delegates generic raw-model hints to
`tinyagents::harness::model::context_window_for_model_id` after checking
OpenHuman tier aliases and the cost catalog; and the observed run path now
drives `invoke_stream_in_context`, whose `Send` stream fix shipped in
[tinyagents#28](https://github.com/tinyhumansai/tinyagents/pull/28).
### 0.4 Contribution workflow (same convention as tinycortex)
@@ -75,7 +84,7 @@ Engine changes are made **inside `vendor/tinyagents`**, committed on a branch th
| `agent_orchestration/ops.rs` (`AgentOrchestrationSession`) + `running_subagents.rs` (1.9k L) | **delete** in favor of `graph::orchestration::TaskStore` + `SteeringRegistry` + `SubAgentSession`; route the live control path through the crate (today only re-exported, per seam `orchestration.rs:23-33`) | n/a |
| `agent_orchestration/types.rs` `AgentStatus` vocabulary | reconcile into `OrchestrationTaskStatus` (two parallel status enums today) | `graph/orchestration/types` |
| `agent_orchestration/workflow_runs/` phase-DAG validation + `agent_teams/` dependency-DAG/atomic-claim/quality-gate logic | evaluate upstreaming the *validation/scheduling slices* as graph extensions; durability (`session_db::run_ledger`) and RPC stay host-side | `graph/` |
| Reasoning-content channel (today smuggled via `ContentBlock::ProviderExtension`, seam `convert.rs:26-30`) | port the *concept*: first-class reasoning channel on `AssistantMessage` | `harness/message/` |
| Reasoning-content channel (historically smuggled via `ContentBlock::ProviderExtension`; host now writes `ContentBlock::Thinking` for new messages and only reads legacy `ProviderExtension` from persisted transcripts — seam `convert.rs`) | **largely done** — the crate's first-class typed reasoning channel (`ContentBlock::Thinking`) is live per Phase 1.4 / ledger P1-5 | `harness/message/` |
### 1.2 Stays in OpenHuman (product policy, I/O, surfaces)
@@ -106,23 +115,23 @@ Each phase is a coherent PR set: tinyagents PR(s) → release/tag → host `chor
### Phase 0 — Version alignment & baseline (no behavior change)
1. Bump `vendor/tinyagents` to **v1.6.0** and the host requirement `1.5.0``1.6.0` (both root and `app/src-tauri` manifests; keep the `[patch.crates-io]` path entries).
2. Fix seam fallout from the bump: SHA-256 prompt fingerprint vs the seam's KV-cache drift guard (`tinyagents/middleware.rs`), idempotent `RedactionMiddleware` vs `journal.rs` double-redaction, adopt `ToolCompleted` outcome fields in `observability.rs` (retire `ToolFailureMap` reconstruction), adopt `invoke_stream` in `model.rs`.
1. **Done:** bump `vendor/tinyagents` to **v1.6.0** and the host requirement `1.5.0``1.6.0` (both root and `app/src-tauri` manifests; keep the `[patch.crates-io]` path entries).
2. **Done:** fix seam fallout from the bump: SHA-256 prompt fingerprint vs the seam's KV-cache drift guard (`tinyagents/middleware.rs`), idempotent `RedactionMiddleware` vs `journal.rs` double-redaction, adopt `ToolCompleted` outcome fields in `observability.rs` (retire `ToolFailureMap` reconstruction). The context-preserving `invoke_stream` host path is now closed by the `Send` stream follow-up released in TinyAgents `v1.7.1`.
3. Record a baseline: LOC per module, test counts, and the duplication map (§0.2) as the drift ledger for this migration (mirroring `docs/tinycortex-drift-ledger.md`).
4. **Exit:** host builds and full release lane passes on tinyagents 1.6.0 with zero in-tree deletions yet.
4. **Exit:** host builds and full release lane passes on tinyagents 1.6.0 with zero in-tree deletions yet. The host has since moved on to the Phase 1 `v1.7.1` partial cutover.
### Phase 1 — Quick wins upstream (small tinyagents PRs, no host deletions)
Land these as separate small PRs against tinyagents (each with its ported tests re-expressed in crate conventions):
1. `SchemaCleanr` (+ its 15-test suite) → provider-layer schema cleaning. Zero-dep, highest value/effort ratio.
2. `error_classify.rs` classifiers → retry/HTTP-error classification (strip openhuman-backend phrase matches into a host-side extension table; keep the generic HTTP/status logic).
3. `model_context.rs` pattern-match table (substring-vs-segment matching, incl. the o1/o3 regression guard) → `ModelProfile` context resolution. OH tier aliases and cost-catalog arms stay host-side.
4. First-class **reasoning channel** on `AssistantMessage` → delete the `ProviderExtension` smuggling in seam `convert.rs`.
5. Git-worktree `WorkspaceIsolation` provider from `agent_orchestration/worktree.rs` (+ `worktree_tests.rs`, which exercises real git repos — fits the crate's offline test policy). The single `publish_global` event becomes a host-side wrapper.
6. Tool display metadata (`humanize_tool_name`, `display_label`/`display_detail`, `ToolTimeout` semantics) → `harness/tool/`.
7. `current_time` / `resolve_time` as the first two builtin tools (pilot for the `tools` feature layout).
8. **Exit:** tinyagents 1.7.0 tagged; host bumps and swaps call sites (`SchemaCleanr` imports, worktree isolation, convert.rs reasoning path); duplicated host copies deleted where the swap is complete.
1. `SchemaCleanr` (+ its 15-test suite) → provider-layer schema cleaning. Zero-dep, highest value/effort ratio. **Status:** released in TinyAgents `v1.7.0`; host `tools/schema.rs` re-exports the crate implementation so existing import paths keep working.
2. `error_classify.rs` classifiers → retry/HTTP-error classification (strip openhuman-backend phrase matches into a host-side extension table; keep the generic HTTP/status logic). **Status:** released in TinyAgents `v1.7.0`; host retry/failure call-site swaps remain pending because OpenHuman-specific backend phrase and billing-envelope rules must stay host-side.
3. `model_context.rs` pattern-match table (substring-vs-segment matching, incl. the o1/o3 regression guard) → `ModelProfile` context resolution. OH tier aliases and cost-catalog arms stay host-side. **Status:** released in TinyAgents `v1.7.0`; host now delegates generic fallback lookup to the crate after checking tiers and the OpenHuman cost catalog.
4. First-class **reasoning channel** on `AssistantMessage` → delete the `ProviderExtension` smuggling in seam `convert.rs`. **Status:** closed for new messages: TinyAgents `v1.6.0` provides typed thinking blocks, and the host now writes `reasoning_content` to `ContentBlock::Thinking` while retaining legacy `ProviderExtension` reads.
5. Git-worktree `WorkspaceIsolation` provider from `agent_orchestration/worktree.rs` (+ `worktree_tests.rs`, which exercises real git repos — fits the crate's offline test policy). The single `publish_global` event becomes a host-side wrapper. **Status:** released in TinyAgents `v1.7.0`; OpenHuman's wrapper stays for event-bus emissions, `OutsideWorkspace`, and host policy mapping until a safer adapter pass.
6. Tool display metadata (`humanize_tool_name`, `display_label`/`display_detail`, `ToolTimeout` semantics) → `harness/tool/`. **Status:** released in TinyAgents `v1.7.0`; host adjusted `ToolRuntime.timeout`, but the OpenHuman `Tool` trait still owns richer legacy metadata until Phase 2 reconciles the tool model.
7. `current_time` / `resolve_time` as the first two builtin tools (pilot for the `tools` feature layout). **Status:** released in TinyAgents `v1.7.0` behind the optional `tools` feature; host wrappers stay until the Phase 2 tool model reconciliation adopts crate builtins.
8. **Exit:** TinyAgents `v1.7.1` tagged and host bumped. `SchemaCleanr`, generic model-context lookup, reasoning, and the context-preserving observed stream path are crate-backed; worktree/time/error-classification/display APIs are available but host deletion waits for their adapter/model reconciliation steps.
### Phase 2 — Tool model reconciliation + builtin tool families
@@ -190,7 +199,7 @@ Fix-in-place candidates (independent of the port; several become moot as phases
- `provider/factory.rs:404``NO_MODEL_CONFIGURED_ANCHOR` correctness depends on a string literal matched by a separate classifier in `provider/ops.rs:19`; fragile cross-file coupling.
**tools/**
- `tools/ops.rs``all_tools` takes 12 positional args (`#[allow(clippy::too_many_arguments)]`); a builder is overdue. The `Vec<Box<dyn Tool>>` container doesn't dedupe (a test exists *because* of this); the crate's `ToolRegistry` fixes it structurally.
- `tools/ops.rs``all_tools_with_runtime` takes 12 positional args (`#[allow(clippy::too_many_arguments)]`; the thin `all_tools` shim forwards 9); a builder is overdue. The `Vec<Box<dyn Tool>>` container doesn't dedupe (a test exists *because* of this); the crate's `ToolRegistry` fixes it structurally.
- `tools/traits.rs:15``ToolScope::AgentOnly` is a dead variant; `ToolCategory::Workflow` is pinned to wire `"skill"` (documented tech-debt to resolve before porting the type).
- `tools/traits.rs:367-372``is_concurrency_safe` is advisory-only (harness runs tools serially); tinyagents 1.6 has concurrent independent tool calls — adopting it makes the flag real.
- `orchestrator_tools.rs:38-41,87-89` — dead `SpawnWorkerThreadTool` registration (pending #1624); mis-attached doc-comment at lines 592-603 describes a different test than the one it precedes.
@@ -200,7 +209,7 @@ Fix-in-place candidates (independent of the port; several become moot as phases
**agent_orchestration/**
- `ops.rs:138-168``message_agent` records metadata only and never injects into the running loop; a real functional hole that `agent_teams/runtime.rs` and `command_center` inherit.
- `subagent_control.rs:186-190``SteerError::NotOwned` arm is dead (no ownership check performed).
- `subagent_control.rs` the `SteerError::NotOwned` arm is unreachable on the trusted RPC `steer_control` path (which performs no parent-ownership check), but the variant is **not dead**: the agent-tool `running_subagents::steer` path enforces ownership and returns it (guarded by `steer_rejects_cross_parent_and_unknown`). Real RPC-path enforcement is deferred to Phase 4 (`SteeringRegistry` ownership consolidation).
- `ops.rs:559-562` — in-memory session path hardcodes `worktree_path: None, changed_files: []` on completion even for worktree-isolated workers (inconsistent with `spawn_parallel_graph`).
- `delegation.rs:120-127` — human-approval interrupt permanently disabled; the durable interrupt path in the seam is wired but unreachable until a review surface exists.
- Two parallel status enums (`AgentStatus` vs `OrchestrationTaskStatus`) — latent drift hazard (§2.5).
+46 -57
View File
@@ -46,52 +46,6 @@ const CONSERVATIVE_LOCAL_CONTEXT_FLOOR: u64 = 4_096;
/// chunking, so it must reflect the real backing model's capacity.
const TIER_SUMMARIZATION_CONTEXT: u64 = 1_000_000;
/// How a pattern in [`MODEL_CONTEXT_PATTERNS`] is matched against a model id.
#[derive(Copy, Clone)]
enum PatternMatch {
/// Pattern must appear anywhere as a substring (after lowercasing).
Substring,
/// Pattern must appear as a full `-`/`_`/`/`/`:`-delimited segment.
/// Prevents false positives like `"solo1-7b"` matching the `"o1"` pattern
/// or `"proto3-chat"` matching the `"o3"` pattern.
Segment,
}
/// `(pattern, match mode, context window in tokens)` — first match wins.
const MODEL_CONTEXT_PATTERNS: &[(&str, PatternMatch, u64)] = &[
("claude-haiku-4.5", PatternMatch::Substring, 200_000),
("claude-haiku-4", PatternMatch::Substring, 200_000),
("claude-haiku", PatternMatch::Substring, 200_000),
("claude-sonnet-4", PatternMatch::Substring, 200_000),
("claude-opus-4", PatternMatch::Substring, 200_000),
("claude-3-5-sonnet", PatternMatch::Substring, 200_000),
("claude-3-5-haiku", PatternMatch::Substring, 200_000),
("claude-3-opus", PatternMatch::Substring, 200_000),
("gpt-4.1", PatternMatch::Substring, 1_047_576),
("gpt-4o", PatternMatch::Substring, 128_000),
("gpt-4-turbo", PatternMatch::Substring, 128_000),
("gpt-4", PatternMatch::Substring, 128_000),
("gpt-3.5", PatternMatch::Substring, 16_385),
// `o1`/`o3` are short and collide with substrings of unrelated model ids
// (e.g. `solo1-7b`, `proto3-chat`). Require a segment-boundary match.
("o1", PatternMatch::Segment, 200_000),
("o3", PatternMatch::Segment, 200_000),
("deepseek", PatternMatch::Substring, 128_000),
("gemma3", PatternMatch::Substring, 8_192),
("gemma", PatternMatch::Substring, 8_192),
("llama-3", PatternMatch::Substring, 128_000),
("llama3", PatternMatch::Substring, 128_000),
];
fn matches_pattern(lower: &str, pattern: &str, mode: PatternMatch) -> bool {
match mode {
PatternMatch::Substring => lower.contains(pattern),
PatternMatch::Segment => lower
.split(|c: char| matches!(c, '/' | '-' | '_' | ':' | '.'))
.any(|seg| seg == pattern),
}
}
/// Resolve the context window (in tokens) for a model id or OpenHuman tier alias.
///
/// Returns `None` when the model is unknown — callers should skip pre-dispatch
@@ -116,19 +70,54 @@ pub fn context_window_for_model(model: &str) -> Option<u64> {
return Some(u64::from(price.context_window));
}
let lower = normalized.to_ascii_lowercase();
for (pattern, mode, window) in MODEL_CONTEXT_PATTERNS {
if matches_pattern(&lower, pattern, *mode) {
tracing::debug!(
model = normalized,
pattern,
context_window = window,
"[model_context] matched known model pattern"
);
return Some(*window);
}
if let Some(window) = tinyagents::harness::model::context_window_for_model_id(normalized) {
tracing::debug!(
model = normalized,
context_window = window,
"[model_context] matched tinyagents model context hint"
);
return Some(window);
}
// The crate's `context_window_for_model_id` resolves the canonical o1/o3
// ids (`o1`, `o1-mini`, `openai/o1-preview`, …) but does not match an
// `o1`/`o3` token embedded mid-name (e.g. `ollama/mistral-for-o1-benchmark`).
// OpenHuman keeps that segment heuristic host-side to preserve the
// pre-port behavior (regression guard from PR #2100) until the crate
// matcher covers internal segments.
if let Some(window) = o1_o3_segment_context(normalized) {
tracing::debug!(
model = normalized,
context_window = window,
"[model_context] matched host o1/o3 segment heuristic"
);
return Some(window);
}
None
}
/// Match a bounded `o1`/`o3` segment anywhere in the model id and map it to the
/// OpenAI reasoning-model 200K window. The token must be delimited by a
/// non-alphanumeric boundary (or string start/end) on both sides so substrings
/// like `solo1-7b`, `proto3-chat`, or `octo3thing` do **not** over-match.
fn o1_o3_segment_context(model: &str) -> Option<u64> {
const O_SERIES_CONTEXT: u64 = 200_000;
let bytes = model.as_bytes();
let n = bytes.len();
let mut i = 0;
while i + 1 < n {
let is_o = bytes[i] == b'o' || bytes[i] == b'O';
if is_o && (bytes[i + 1] == b'1' || bytes[i + 1] == b'3') {
let before_ok = i == 0 || !bytes[i - 1].is_ascii_alphanumeric();
let after = i + 2;
let after_ok = after >= n || !bytes[after].is_ascii_alphanumeric();
if before_ok && after_ok {
return Some(O_SERIES_CONTEXT);
}
}
i += 1;
}
None
}
+69 -13
View File
@@ -23,30 +23,29 @@ use tinyagents::harness::tool::{ToolCall as TaToolCall, ToolSchema};
use crate::openhuman::inference::provider::{ChatMessage, ConversationMessage, ToolResultMessage};
use crate::openhuman::tools::ToolSpec;
/// Key under which a thinking model's `reasoning_content` is stashed on an
/// assistant [`Message`]'s content blocks (as a [`ContentBlock::ProviderExtension`])
/// and echoed back through openhuman [`ChatMessage::extra_metadata`]. tinyagents'
/// `AssistantMessage` has no reasoning channel of its own, so we carry it here so
/// it survives the round-trip and can be replayed verbatim on the next turn
/// (thinking-mode providers reject a multi-turn request that drops it).
/// Key under which a thinking model's `reasoning_content` is echoed through
/// openhuman [`ChatMessage::extra_metadata`]. New harness transcripts carry
/// reasoning as [`ContentBlock::Thinking`]; legacy persisted transcripts may
/// still have the same key inside [`ContentBlock::ProviderExtension`].
pub(super) const REASONING_EXT_KEY: &str = "reasoning_content";
/// Build the [`ContentBlock`] that stashes a response's `reasoning_content` on an
/// assistant message, if any.
/// Build the [`ContentBlock`] that carries a response's `reasoning_content` on
/// an assistant message, if any.
pub(super) fn reasoning_content_block(reasoning: Option<&str>) -> Option<ContentBlock> {
let reasoning = reasoning?;
// Store verbatim (only gate on non-empty after a trim): thinking-mode
// providers validate the prior reasoning block byte-for-byte on a resumed
// multi-turn request, so trimming boundary whitespace could break replay.
(!reasoning.trim().is_empty()).then(|| {
ContentBlock::ProviderExtension(serde_json::json!({ REASONING_EXT_KEY: reasoning }))
(!reasoning.trim().is_empty()).then(|| ContentBlock::Thinking {
text: reasoning.to_string(),
signature: None,
})
}
/// Recover the stashed `reasoning_content` from an assistant message's content
/// blocks (see [`reasoning_content_block`]).
/// Recover `reasoning_content` from an assistant message's content blocks.
fn reasoning_from_content(content: &[ContentBlock]) -> Option<String> {
content.iter().find_map(|block| match block {
ContentBlock::Thinking { text, .. } => Some(text.clone()),
ContentBlock::ProviderExtension(value) => value
.get(REASONING_EXT_KEY)
.and_then(serde_json::Value::as_str)
@@ -55,7 +54,7 @@ fn reasoning_from_content(content: &[ContentBlock]) -> Option<String> {
})
}
/// The `extra_metadata` an assistant [`ChatMessage`] should carry so a stashed
/// The `extra_metadata` an assistant [`ChatMessage`] should carry so
/// `reasoning_content` replays on the next provider request.
fn reasoning_extra_metadata(content: &[ContentBlock]) -> Option<serde_json::Value> {
reasoning_from_content(content)
@@ -486,6 +485,63 @@ mod tests {
assert_eq!(a.text(), "just a normal reply");
}
#[test]
fn reasoning_content_uses_typed_thinking_block_and_round_trips_metadata() {
let mut chat = ChatMessage::assistant("visible answer");
chat.extra_metadata = Some(serde_json::json!({ REASONING_EXT_KEY: "private thoughts" }));
let msg = chat_message_to_message(&chat);
let Message::Assistant(assistant) = &msg else {
panic!("expected Assistant, got {msg:?}");
};
assert_eq!(msg.text(), "visible answer");
assert!(assistant.content.iter().any(|block| {
matches!(
block,
ContentBlock::Thinking { text, signature: None } if text == "private thoughts"
)
}));
assert!(!assistant
.content
.iter()
.any(|block| matches!(block, ContentBlock::ProviderExtension(_))));
let back = message_to_chat_message(&msg);
assert_eq!(back.content, "visible answer");
assert_eq!(
back.extra_metadata
.as_ref()
.and_then(|meta| meta.get(REASONING_EXT_KEY))
.and_then(serde_json::Value::as_str),
Some("private thoughts")
);
}
#[test]
fn legacy_provider_extension_reasoning_still_round_trips() {
let msg = Message::Assistant(AssistantMessage {
id: None,
content: vec![
ContentBlock::Text("visible answer".into()),
ContentBlock::ProviderExtension(
serde_json::json!({ REASONING_EXT_KEY: "legacy thoughts" }),
),
],
tool_calls: vec![],
usage: None,
});
let back = message_to_chat_message(&msg);
assert_eq!(back.content, "visible answer");
assert_eq!(
back.extra_metadata
.as_ref()
.and_then(|meta| meta.get(REASONING_EXT_KEY))
.and_then(serde_json::Value::as_str),
Some("legacy thoughts")
);
}
#[test]
fn roles_round_trip_through_the_bridge() {
let history = vec![
+94 -41
View File
@@ -25,6 +25,7 @@ use std::sync::atomic::{AtomicBool, AtomicU32, AtomicUsize, Ordering};
use std::sync::Arc;
use async_trait::async_trait;
use sha2::{Digest, Sha256};
use tinyagents::error::{Result as TaResult, TinyAgentsError};
use tinyagents::harness::context::RunContext;
@@ -623,21 +624,20 @@ fn parse_context_bundle_has_enough_context(bundle: &str) -> Option<bool> {
}
}
/// Seed-free FNV-1a fingerprint (matches the crate's own prompt-layout hash
/// approach) so a segment id is stable across process restarts — unlike Rust's
/// randomly-seeded `SipHash`. Used to build content-fingerprinted prompt-cache
/// segment ids: an unchanged system prompt / tool set keeps the same id (stable
/// prefix), while injected volatile content flips it and surfaces as a
/// `CacheLayoutEvent`.
fn stable_prefix_fingerprint(data: &str) -> String {
const OFFSET_BASIS: u64 = 14_695_981_039_346_656_037;
const PRIME: u64 = 1_099_511_628_211;
let mut hash = OFFSET_BASIS;
for &byte in data.as_bytes() {
hash ^= u64::from(byte);
hash = hash.wrapping_mul(PRIME);
/// Stable SHA-256 fingerprint over canonical JSON. TinyAgents' prompt builder
/// uses the same shape for `ModelRequest::prompt_fingerprint`; OpenHuman builds
/// requests directly, so this adapter must stamp equivalent content-derived
/// segment ids and request fingerprints.
fn stable_prefix_fingerprint(value: &serde_json::Value) -> String {
let mut hasher = Sha256::new();
if serde_json::to_writer(&mut hasher, value).is_err() {
hasher = Sha256::new();
}
format!("{hash:016x}")
hasher
.finalize()
.iter()
.map(|byte| format!("{byte:02x}"))
.collect()
}
/// `before_model`: declare the turn's stable prompt prefix (system prompt + tool
@@ -645,10 +645,11 @@ fn stable_prefix_fingerprint(data: &str) -> String {
///
/// OpenHuman assembles the request's messages/tools directly rather than through
/// the crate prompt builder, so `cache_segments` would otherwise stay empty and
/// the crate `PromptCacheGuardMiddleware` (installed immediately after this) would
/// have no prefix to protect. This stamps the segments with **content-fingerprint
/// ids**: an unchanged system prompt + tool set yields a stable prefix, while an
/// injected timestamp/uuid/etc. changes the fingerprint and the guard records a
/// the crate `PromptCacheGuardMiddleware` (installed immediately after this)
/// would have no prefix to protect. This stamps the segments with
/// **content-fingerprint ids**: an unchanged system prompt + full tool-schema set
/// yields a stable prefix, while an injected timestamp/uuid/etc. or changed tool
/// schema flips it and the guard records a
/// [`CacheLayoutEvent`](tinyagents::harness::cache::CacheLayoutEvent). This is
/// the structured, crate-native replacement for the deleted warn-only
/// `CacheAlignMiddleware` volatile-token scan (C3): the crate
@@ -676,24 +677,25 @@ impl Middleware<()> for PromptCacheSegmentMiddleware {
.iter()
.find(|m| matches!(m, TaMessage::System(_)))
{
let fp = stable_prefix_fingerprint(&sys.text());
let fp = stable_prefix_fingerprint(&serde_json::json!({
"role": "system",
"messages": [sys],
}));
segments.push(PromptSegment {
id: format!("system:{fp}"),
role: SegmentRole::System,
cacheable: true,
});
}
// 2. Tool schemas — advertised tool *set* identity (names, in registration
// order) forms the next stable prefix segment. A changed tool surface
// legitimately busts the prefix; an unchanged one keeps it stable.
// 2. Tool schemas — advertised tool surface identity (full schemas, in
// registration order) forms the next stable prefix segment. A changed
// tool surface legitimately busts the prefix; an unchanged one keeps
// it stable.
if !request.tools.is_empty() {
let joined = request
.tools
.iter()
.map(|t| t.name.as_str())
.collect::<Vec<_>>()
.join(",");
let fp = stable_prefix_fingerprint(&joined);
let fp = stable_prefix_fingerprint(&serde_json::json!({
"role": "tools",
"tools": &request.tools,
}));
segments.push(PromptSegment {
id: format!("tools:{fp}"),
role: SegmentRole::Tools,
@@ -701,12 +703,10 @@ impl Middleware<()> for PromptCacheSegmentMiddleware {
});
}
if !segments.is_empty() {
let joined_ids = segments
.iter()
.map(|s| s.id.as_str())
.collect::<Vec<_>>()
.join("|");
request.prompt_fingerprint = Some(stable_prefix_fingerprint(&joined_ids));
request.prompt_fingerprint = Some(stable_prefix_fingerprint(&serde_json::json!({
"segments": &segments,
"tools": &request.tools,
})));
tracing::debug!(
segment_count = segments.len(),
fingerprint = request.prompt_fingerprint.as_deref().unwrap_or(""),
@@ -1349,9 +1349,10 @@ impl ToolMiddleware<()> for ToolPolicyMiddleware {
/// raw payload.
pub(crate) struct ToolOutcomeCaptureMiddleware {
sink: super::ToolOutcomeSink,
/// `call_id → (success, classified failure)` side-channel read by the event
/// bridge when projecting `ToolCallCompleted` (the crate event lacks the
/// success/error the failure UI needs).
/// `call_id → (success, classified failure, elapsed, output chars)` fallback
/// read by the event bridge when projecting `ToolCallCompleted`. TinyAgents
/// 1.6 owns the raw outcome fields; the host still adds classified failure
/// metadata for the UI.
failure_map: super::observability::ToolFailureMap,
}
@@ -1423,9 +1424,9 @@ impl Middleware<()> for ToolOutcomeCaptureMiddleware {
))
};
if let Ok(mut map) = self.failure_map.lock() {
// Also carry the executor-measured duration + rendered output size so
// the event bridge can project real `elapsed_ms`/`output_chars` on
// `ToolCallCompleted` instead of `0`/`0` (#4467, item 4).
// Keep duration + rendered output size as a compatibility fallback
// for old/deserialized completion events; TinyAgents 1.6 supplies
// these fields directly on live `ToolCompleted` events.
map.insert(
result.call_id.clone(),
(
@@ -3188,6 +3189,58 @@ mod tests {
RunContext::new(RunConfig::new("mw-test"), ())
}
#[tokio::test]
async fn prompt_cache_segments_fingerprint_full_tool_schema() {
let mw = PromptCacheSegmentMiddleware;
let mut first =
ModelRequest::new(vec![TaMessage::system("sys")]).with_tools(vec![ToolSchema::new(
"lookup",
"lookup a user",
json!({
"type": "object",
"properties": {
"id": { "type": "string" }
},
}),
)]);
mw.before_model(&mut ctx(), &(), &mut first).await.unwrap();
let mut second =
ModelRequest::new(vec![TaMessage::system("sys")]).with_tools(vec![ToolSchema::new(
"lookup",
"lookup a user",
json!({
"type": "object",
"properties": {
"id": { "type": "integer" }
},
}),
)]);
mw.before_model(&mut ctx(), &(), &mut second).await.unwrap();
let first_tool_segment = first
.cache_segments
.iter()
.find(|segment| segment.role == SegmentRole::Tools)
.expect("tool segment");
let second_tool_segment = second
.cache_segments
.iter()
.find(|segment| segment.role == SegmentRole::Tools)
.expect("tool segment");
assert_ne!(
first_tool_segment.id, second_tool_segment.id,
"same-name tools with different schemas must bust the stable prefix"
);
assert_ne!(first.prompt_fingerprint, second.prompt_fingerprint);
assert_eq!(
first.prompt_fingerprint.as_deref().unwrap().len(),
64,
"request prompt fingerprints use TinyAgents' SHA-256 shape"
);
}
/// A minimal openhuman [`Tool`] for the tool-setbacked middlewares. Its
/// `max_result_size_chars` and `external_effect` are configurable so the
/// budget/approval resolution paths can be exercised.
+23 -2
View File
@@ -44,6 +44,8 @@ mod topology;
use std::sync::Arc;
use anyhow::Result;
use futures::StreamExt;
use tinyagents::harness::agent_loop::AgentStreamItem;
use tinyagents::harness::cache::InMemoryResponseCache;
use tinyagents::harness::context::{RunConfig, RunContext};
use tinyagents::harness::events::EventSink;
@@ -396,7 +398,7 @@ pub(crate) async fn run_turn_via_tinyagents(
/// silently inheriting the parent's full tool surface (shell/file-write/spawn).
/// Each registered tool is advertised via its own `spec()`.
///
/// When `on_progress` is `Some`, the run streams (`invoke_streaming_in_context`)
/// When `on_progress` is `Some`, the run streams (`invoke_stream_in_context`)
/// and a [`OpenhumanEventBridge`] mirrors the harness event stream onto
/// `AgentProgress` (live tool timeline, text deltas, cost/token footer) and the
/// global cost tracker — restoring the seams the legacy `run_turn_engine`
@@ -780,7 +782,26 @@ pub(crate) async fn run_turn_via_tinyagents_shared(
// pointer on the stack at each level.
let run_result = with_run_cancellation(cancellation.clone(), async {
if streaming {
Box::pin(harness.invoke_streaming_in_context(&(), ctx, input)).await
let mut stream = Box::pin(harness.invoke_stream_in_context(&(), ctx, input));
let mut terminal = None;
while let Some(item) = stream.next().await {
match item {
AgentStreamItem::Event(_) => {}
AgentStreamItem::Completed(run) => {
terminal = Some(Ok(*run));
break;
}
AgentStreamItem::Failed(error) => {
terminal = Some(Err(tinyagents::TinyAgentsError::Model(error)));
break;
}
}
}
terminal.unwrap_or_else(|| {
Err(tinyagents::TinyAgentsError::Model(
"tinyagents stream ended without terminal run".to_string(),
))
})
} else {
Box::pin(harness.invoke_in_context(&(), ctx, input)).await
}
+3 -4
View File
@@ -236,10 +236,9 @@ fn response_to_model_response(
content.push(ContentBlock::Text(visible_text));
}
// Thinking models return `reasoning_content` separately from the visible
// reply; tinyagents' `AssistantMessage` has no reasoning channel, so stash it
// on a provider-extension content block. It stays out of `Message::text()`
// (which only concatenates `Text` blocks) but survives into persistence and
// the next turn's request — where thinking-mode providers require it back.
// reply. Preserve it as a typed thinking block so it stays out of
// `Message::text()` but survives persistence and the next turn's request,
// where thinking-mode providers require it back.
if let Some(block) =
super::convert::reasoning_content_block(response.reasoning_content.as_deref())
{
+3 -537
View File
@@ -1,540 +1,6 @@
//! JSON Schema cleaning and validation for LLM tool-calling compatibility.
//!
//! Different providers support different subsets of JSON Schema. This module
//! normalizes tool schemas to improve cross-provider compatibility while
//! preserving semantic intent.
//!
//! ## What this module does
//!
//! 1. Removes unsupported keywords per provider strategy
//! 2. Resolves local `$ref` entries from `$defs` and `definitions`
//! 3. Flattens literal `anyOf` / `oneOf` unions into `enum`
//! 4. Strips nullable variants from unions and `type` arrays
//! 5. Converts `const` to single-value `enum`
//! 6. Detects circular references and stops recursion safely
//!
//! # Example
//!
//! ```rust
//! use serde_json::json;
//! use openhuman_core::openhuman::tools::schema::SchemaCleanr;
//!
//! let dirty_schema = json!({
//! "type": "object",
//! "properties": {
//! "name": {
//! "type": "string",
//! "minLength": 1, // Gemini rejects this
//! "pattern": "^[a-z]+$" // Gemini rejects this
//! },
//! "age": {
//! "$ref": "#/$defs/Age" // Needs resolution
//! }
//! },
//! "$defs": {
//! "Age": {
//! "type": "integer",
//! "minimum": 0 // Gemini rejects this
//! }
//! }
//! });
//!
//! let cleaned = SchemaCleanr::clean_for_gemini(dirty_schema);
//!
//! // Result:
//! // {
//! // "type": "object",
//! // "properties": {
//! // "name": { "type": "string" },
//! // "age": { "type": "integer" }
//! // }
//! // }
//! ```
//!
use serde_json::{json, Map, Value};
use std::collections::{HashMap, HashSet};
//! The generic implementation lives in TinyAgents. OpenHuman keeps this module
//! as the stable host import path for existing tools and controller code.
/// Keywords that Gemini rejects for tool schemas.
pub const GEMINI_UNSUPPORTED_KEYWORDS: &[&str] = &[
// Schema composition
"$ref",
"$schema",
"$id",
"$defs",
"definitions",
// Property constraints
"additionalProperties",
"patternProperties",
// String constraints
"minLength",
"maxLength",
"pattern",
"format",
// Number constraints
"minimum",
"maximum",
"multipleOf",
// Array constraints
"minItems",
"maxItems",
"uniqueItems",
// Object constraints
"minProperties",
"maxProperties",
// Non-standard
"examples", // OpenAPI keyword, not JSON Schema
];
/// Keywords that should be preserved during cleaning (metadata).
const SCHEMA_META_KEYS: &[&str] = &["description", "title", "default"];
/// Schema cleaning strategies for different LLM providers.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CleaningStrategy {
/// Gemini (Google AI / Vertex AI) - Most restrictive
Gemini,
/// Anthropic Claude - Moderately permissive
Anthropic,
/// OpenAI GPT - Most permissive
OpenAI,
/// Conservative: Remove only universally unsupported keywords
Conservative,
}
impl CleaningStrategy {
/// Get the list of unsupported keywords for this strategy.
pub fn unsupported_keywords(self) -> &'static [&'static str] {
match self {
Self::Gemini => GEMINI_UNSUPPORTED_KEYWORDS,
Self::Anthropic => &["$ref", "$defs", "definitions"], // Anthropic doesn't resolve refs
Self::OpenAI => &[], // OpenAI is most permissive
Self::Conservative => &["$ref", "$defs", "definitions", "additionalProperties"],
}
}
}
/// JSON Schema cleaner optimized for LLM tool calling.
pub struct SchemaCleanr;
impl SchemaCleanr {
/// Clean schema for Gemini compatibility (strictest).
///
/// This is the most aggressive cleaning strategy, removing all keywords
/// that Gemini's API rejects.
pub fn clean_for_gemini(schema: Value) -> Value {
Self::clean(schema, CleaningStrategy::Gemini)
}
/// Clean schema for Anthropic compatibility.
pub fn clean_for_anthropic(schema: Value) -> Value {
Self::clean(schema, CleaningStrategy::Anthropic)
}
/// Clean schema for OpenAI compatibility (most permissive).
pub fn clean_for_openai(schema: Value) -> Value {
Self::clean(schema, CleaningStrategy::OpenAI)
}
/// Clean schema with specified strategy.
pub fn clean(schema: Value, strategy: CleaningStrategy) -> Value {
// Extract $defs for reference resolution
let defs = if let Some(obj) = schema.as_object() {
Self::extract_defs(obj)
} else {
HashMap::new()
};
Self::clean_with_defs(schema, &defs, strategy, &mut HashSet::new())
}
/// Validate that a schema is suitable for LLM tool calling.
///
/// Returns an error if the schema is invalid or missing required fields.
pub fn validate(schema: &Value) -> anyhow::Result<()> {
let obj = schema
.as_object()
.ok_or_else(|| anyhow::anyhow!("Schema must be an object"))?;
// Must have 'type' field
if !obj.contains_key("type") {
anyhow::bail!("Schema missing required 'type' field");
}
// If type is 'object', should have 'properties'
if let Some(Value::String(t)) = obj.get("type") {
if t == "object" && !obj.contains_key("properties") {
tracing::warn!("Object schema without 'properties' field may cause issues");
}
}
Ok(())
}
// --------------------------------------------------------------------
// Internal implementation
// --------------------------------------------------------------------
/// Extract $defs and definitions into a flat map for reference resolution.
fn extract_defs(obj: &Map<String, Value>) -> HashMap<String, Value> {
let mut defs = HashMap::new();
// Extract from $defs (JSON Schema 2019-09+)
if let Some(Value::Object(defs_obj)) = obj.get("$defs") {
for (key, value) in defs_obj {
defs.insert(key.clone(), value.clone());
}
}
// Extract from definitions (JSON Schema draft-07)
if let Some(Value::Object(defs_obj)) = obj.get("definitions") {
for (key, value) in defs_obj {
defs.insert(key.clone(), value.clone());
}
}
defs
}
/// Recursively clean a schema value.
fn clean_with_defs(
schema: Value,
defs: &HashMap<String, Value>,
strategy: CleaningStrategy,
ref_stack: &mut HashSet<String>,
) -> Value {
match schema {
Value::Object(obj) => Self::clean_object(obj, defs, strategy, ref_stack),
Value::Array(arr) => Value::Array(
arr.into_iter()
.map(|v| Self::clean_with_defs(v, defs, strategy, ref_stack))
.collect(),
),
other => other,
}
}
/// Clean an object schema.
fn clean_object(
obj: Map<String, Value>,
defs: &HashMap<String, Value>,
strategy: CleaningStrategy,
ref_stack: &mut HashSet<String>,
) -> Value {
// Handle $ref resolution
if let Some(Value::String(ref_value)) = obj.get("$ref") {
return Self::resolve_ref(ref_value, &obj, defs, strategy, ref_stack);
}
// Handle anyOf/oneOf simplification
if obj.contains_key("anyOf") || obj.contains_key("oneOf") {
if let Some(simplified) = Self::try_simplify_union(&obj, defs, strategy, ref_stack) {
return simplified;
}
}
// Build cleaned object
let mut cleaned = Map::new();
let unsupported: HashSet<&str> = strategy.unsupported_keywords().iter().copied().collect();
let has_union = obj.contains_key("anyOf") || obj.contains_key("oneOf");
for (key, value) in obj {
// Skip unsupported keywords
if unsupported.contains(key.as_str()) {
continue;
}
// Special handling for specific keys
match key.as_str() {
// Convert const to enum
"const" => {
cleaned.insert("enum".to_string(), json!([value]));
}
// Skip type if we have anyOf/oneOf (they define the type)
"type" if has_union => {
// Skip
}
// Handle type arrays (remove null)
"type" if matches!(value, Value::Array(_)) => {
let cleaned_value = Self::clean_type_array(value);
cleaned.insert(key, cleaned_value);
}
// Recursively clean nested schemas
"properties" => {
let cleaned_value = Self::clean_properties(value, defs, strategy, ref_stack);
cleaned.insert(key, cleaned_value);
}
"items" => {
let cleaned_value = Self::clean_with_defs(value, defs, strategy, ref_stack);
cleaned.insert(key, cleaned_value);
}
"anyOf" | "oneOf" | "allOf" => {
let cleaned_value = Self::clean_union(value, defs, strategy, ref_stack);
cleaned.insert(key, cleaned_value);
}
// Keep all other keys, cleaning nested objects/arrays recursively.
_ => {
let cleaned_value = match value {
Value::Object(_) | Value::Array(_) => {
Self::clean_with_defs(value, defs, strategy, ref_stack)
}
other => other,
};
cleaned.insert(key, cleaned_value);
}
}
}
Value::Object(cleaned)
}
/// Resolve a $ref to its definition.
fn resolve_ref(
ref_value: &str,
obj: &Map<String, Value>,
defs: &HashMap<String, Value>,
strategy: CleaningStrategy,
ref_stack: &mut HashSet<String>,
) -> Value {
// Prevent circular references
if ref_stack.contains(ref_value) {
tracing::warn!("Circular $ref detected: {}", ref_value);
return Self::preserve_meta(obj, Value::Object(Map::new()));
}
// Try to resolve local ref (#/$defs/Name or #/definitions/Name)
if let Some(def_name) = Self::parse_local_ref(ref_value) {
if let Some(definition) = defs.get(def_name.as_str()) {
ref_stack.insert(ref_value.to_string());
let cleaned = Self::clean_with_defs(definition.clone(), defs, strategy, ref_stack);
ref_stack.remove(ref_value);
return Self::preserve_meta(obj, cleaned);
}
}
// Can't resolve: return empty object with metadata
tracing::warn!("Cannot resolve $ref: {}", ref_value);
Self::preserve_meta(obj, Value::Object(Map::new()))
}
/// Parse a local JSON Pointer ref (#/$defs/Name).
fn parse_local_ref(ref_value: &str) -> Option<String> {
ref_value
.strip_prefix("#/$defs/")
.or_else(|| ref_value.strip_prefix("#/definitions/"))
.map(Self::decode_json_pointer)
}
/// Decode JSON Pointer escaping (`~0` = `~`, `~1` = `/`).
fn decode_json_pointer(segment: &str) -> String {
if !segment.contains('~') {
return segment.to_string();
}
let mut decoded = String::with_capacity(segment.len());
let mut chars = segment.chars().peekable();
while let Some(ch) = chars.next() {
if ch == '~' {
match chars.peek().copied() {
Some('0') => {
chars.next();
decoded.push('~');
}
Some('1') => {
chars.next();
decoded.push('/');
}
_ => decoded.push('~'),
}
} else {
decoded.push(ch);
}
}
decoded
}
/// Try to simplify anyOf/oneOf to a simpler form.
fn try_simplify_union(
obj: &Map<String, Value>,
defs: &HashMap<String, Value>,
strategy: CleaningStrategy,
ref_stack: &mut HashSet<String>,
) -> Option<Value> {
let union_key = if obj.contains_key("anyOf") {
"anyOf"
} else if obj.contains_key("oneOf") {
"oneOf"
} else {
return None;
};
let variants = obj.get(union_key)?.as_array()?;
// Clean all variants first
let cleaned_variants: Vec<Value> = variants
.iter()
.map(|v| Self::clean_with_defs(v.clone(), defs, strategy, ref_stack))
.collect();
// Strip null variants
let non_null: Vec<Value> = cleaned_variants
.into_iter()
.filter(|v| !Self::is_null_schema(v))
.collect();
// If only one variant remains after stripping nulls, return it
if non_null.len() == 1 {
return Some(Self::preserve_meta(obj, non_null[0].clone()));
}
// Try to flatten to enum if all variants are literals
if let Some(enum_value) = Self::try_flatten_literal_union(&non_null) {
return Some(Self::preserve_meta(obj, enum_value));
}
None
}
/// Check if a schema represents null type.
fn is_null_schema(value: &Value) -> bool {
if let Some(obj) = value.as_object() {
// { const: null }
if let Some(Value::Null) = obj.get("const") {
return true;
}
// { enum: [null] }
if let Some(Value::Array(arr)) = obj.get("enum") {
if arr.len() == 1 && matches!(arr[0], Value::Null) {
return true;
}
}
// { type: "null" }
if let Some(Value::String(t)) = obj.get("type") {
if t == "null" {
return true;
}
}
}
false
}
/// Try to flatten anyOf/oneOf with only literal values to enum.
///
/// Example: `anyOf: [{const: "a"}, {const: "b"}]` -> `{type: "string", enum: ["a", "b"]}`
fn try_flatten_literal_union(variants: &[Value]) -> Option<Value> {
if variants.is_empty() {
return None;
}
let mut all_values = Vec::new();
let mut common_type: Option<String> = None;
for variant in variants {
let obj = variant.as_object()?;
// Extract literal value from const or single-item enum
let literal_value = if let Some(const_val) = obj.get("const") {
const_val.clone()
} else if let Some(Value::Array(arr)) = obj.get("enum") {
if arr.len() == 1 {
arr[0].clone()
} else {
return None;
}
} else {
return None;
};
// Check type consistency
let variant_type = obj.get("type")?.as_str()?;
match &common_type {
None => common_type = Some(variant_type.to_string()),
Some(t) if t != variant_type => return None,
_ => {}
}
all_values.push(literal_value);
}
common_type.map(|t| {
json!({
"type": t,
"enum": all_values
})
})
}
/// Clean type array, removing null.
fn clean_type_array(value: Value) -> Value {
if let Value::Array(types) = value {
let non_null: Vec<Value> = types
.into_iter()
.filter(|v| v.as_str() != Some("null"))
.collect();
match non_null.len() {
0 => Value::String("null".to_string()),
1 => non_null
.into_iter()
.next()
.unwrap_or(Value::String("null".to_string())),
_ => Value::Array(non_null),
}
} else {
value
}
}
/// Clean properties object.
fn clean_properties(
value: Value,
defs: &HashMap<String, Value>,
strategy: CleaningStrategy,
ref_stack: &mut HashSet<String>,
) -> Value {
if let Value::Object(props) = value {
let cleaned: Map<String, Value> = props
.into_iter()
.map(|(k, v)| (k, Self::clean_with_defs(v, defs, strategy, ref_stack)))
.collect();
Value::Object(cleaned)
} else {
value
}
}
/// Clean union (anyOf/oneOf/allOf).
fn clean_union(
value: Value,
defs: &HashMap<String, Value>,
strategy: CleaningStrategy,
ref_stack: &mut HashSet<String>,
) -> Value {
if let Value::Array(variants) = value {
let cleaned: Vec<Value> = variants
.into_iter()
.map(|v| Self::clean_with_defs(v, defs, strategy, ref_stack))
.collect();
Value::Array(cleaned)
} else {
value
}
}
/// Preserve metadata (description, title, default) from source to target.
fn preserve_meta(source: &Map<String, Value>, mut target: Value) -> Value {
if let Value::Object(target_obj) = &mut target {
for &key in SCHEMA_META_KEYS {
if let Some(value) = source.get(key) {
target_obj.insert(key.to_string(), value.clone());
}
}
}
target
}
}
#[cfg(test)]
#[path = "schema_tests.rs"]
mod tests;
pub use tinyagents::harness::tool::{CleaningStrategy, SchemaCleanr, GEMINI_UNSUPPORTED_KEYWORDS};