From ebc81b78ded7ee0e9d1326e5a63c11a7825d2f82 Mon Sep 17 00:00:00 2001 From: Steven Enamakel <31011319+senamakel@users.noreply.github.com> Date: Sun, 5 Jul 2026 03:01:28 -0700 Subject: [PATCH] =?UTF-8?q?feat(tinyagents):=20Phase=200/1=20port=20cutove?= =?UTF-8?q?r=20=E2=80=94=20pin=20v1.7.1,=20crate-back=20SchemaCleanr=20/?= =?UTF-8?q?=20model-context=20/=20observed=20stream=20(#4539)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/tinyagents-drift-ledger.md | 103 +++++ docs/tinyagents-port-plan.md | 51 ++- src/openhuman/inference/model_context.rs | 103 ++--- src/openhuman/tinyagents/convert.rs | 82 +++- src/openhuman/tinyagents/middleware.rs | 135 ++++-- src/openhuman/tinyagents/mod.rs | 25 +- src/openhuman/tinyagents/model.rs | 7 +- src/openhuman/tools/schema.rs | 540 +---------------------- 8 files changed, 371 insertions(+), 675 deletions(-) create mode 100644 docs/tinyagents-drift-ledger.md diff --git a/docs/tinyagents-drift-ledger.md b/docs/tinyagents-drift-ledger.md new file mode 100644 index 000000000..e221a1a9e --- /dev/null +++ b/docs/tinyagents-drift-ledger.md @@ -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. diff --git a/docs/tinyagents-port-plan.md b/docs/tinyagents-port-plan.md index a8e30415b..b117d4d34 100644 --- a/docs/tinyagents-port-plan.md +++ b/docs/tinyagents-port-plan.md @@ -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`, 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`, 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>` 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>` 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). diff --git a/src/openhuman/inference/model_context.rs b/src/openhuman/inference/model_context.rs index 665adda65..1926bd71b 100644 --- a/src/openhuman/inference/model_context.rs +++ b/src/openhuman/inference/model_context.rs @@ -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 { 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 { + 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 } diff --git a/src/openhuman/tinyagents/convert.rs b/src/openhuman/tinyagents/convert.rs index bf2cddb2d..1002052e3 100644 --- a/src/openhuman/tinyagents/convert.rs +++ b/src/openhuman/tinyagents/convert.rs @@ -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 { 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 { 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 { }) } -/// 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 { 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![ diff --git a/src/openhuman/tinyagents/middleware.rs b/src/openhuman/tinyagents/middleware.rs index de50ff7b9..2ee16b12d 100644 --- a/src/openhuman/tinyagents/middleware.rs +++ b/src/openhuman/tinyagents/middleware.rs @@ -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 { } } -/// 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::>() - .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::>() - .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-set–backed middlewares. Its /// `max_result_size_chars` and `external_effect` are configurable so the /// budget/approval resolution paths can be exercised. diff --git a/src/openhuman/tinyagents/mod.rs b/src/openhuman/tinyagents/mod.rs index 41f2fdb27..9828cde99 100644 --- a/src/openhuman/tinyagents/mod.rs +++ b/src/openhuman/tinyagents/mod.rs @@ -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 } diff --git a/src/openhuman/tinyagents/model.rs b/src/openhuman/tinyagents/model.rs index f7644809c..86da67628 100644 --- a/src/openhuman/tinyagents/model.rs +++ b/src/openhuman/tinyagents/model.rs @@ -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()) { diff --git a/src/openhuman/tools/schema.rs b/src/openhuman/tools/schema.rs index d4bb79520..c6d3e805c 100644 --- a/src/openhuman/tools/schema.rs +++ b/src/openhuman/tools/schema.rs @@ -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) -> HashMap { - 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, - strategy: CleaningStrategy, - ref_stack: &mut HashSet, - ) -> 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, - defs: &HashMap, - strategy: CleaningStrategy, - ref_stack: &mut HashSet, - ) -> 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, - defs: &HashMap, - strategy: CleaningStrategy, - ref_stack: &mut HashSet, - ) -> 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 { - 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, - defs: &HashMap, - strategy: CleaningStrategy, - ref_stack: &mut HashSet, - ) -> Option { - 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 = variants - .iter() - .map(|v| Self::clean_with_defs(v.clone(), defs, strategy, ref_stack)) - .collect(); - - // Strip null variants - let non_null: Vec = 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 { - if variants.is_empty() { - return None; - } - - let mut all_values = Vec::new(); - let mut common_type: Option = 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 = 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, - strategy: CleaningStrategy, - ref_stack: &mut HashSet, - ) -> Value { - if let Value::Object(props) = value { - let cleaned: Map = 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, - strategy: CleaningStrategy, - ref_stack: &mut HashSet, - ) -> Value { - if let Value::Array(variants) = value { - let cleaned: Vec = 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, 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};