mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat(tinyagents): Phase 0/1 port cutover — pin v1.7.1, crate-back SchemaCleanr / model-context / observed stream (#4539)
This commit is contained in:
@@ -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.
|
||||
@@ -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,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
|
||||
}
|
||||
|
||||
|
||||
@@ -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. 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-set–backed middlewares. Its
|
||||
/// `max_result_size_chars` and `external_effect` are configurable so the
|
||||
/// budget/approval resolution paths can be exercised.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
{
|
||||
|
||||
@@ -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};
|
||||
|
||||
Reference in New Issue
Block a user