From 7a67f6ca353c1fc0a57441ddec77994ee104eb15 Mon Sep 17 00:00:00 2001 From: Steven Enamakel <31011319+senamakel@users.noreply.github.com> Date: Wed, 1 Jul 2026 00:27:39 -0700 Subject: [PATCH] =?UTF-8?q?agent=5Fgraph:=20LangGraph-style=20state-machin?= =?UTF-8?q?e=20harness=20=E2=80=94=20checkpointing,=20HITL,=20observabilit?= =?UTF-8?q?y=20(#4249)=20(#4261)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/pr-ci.yml | 3 + Cargo.lock | 17 + Cargo.toml | 13 + app/src-tauri/Cargo.lock | 17 + .../src-tauri/src/fake_camera}/mascot.svg | 0 app/src-tauri/src/fake_camera/mod.rs | 2 +- .../src-tauri/src/meet_video}/Bookreading.svg | 0 .../src-tauri/src/meet_video}/idelMascot.svg | 0 app/src-tauri/src/meet_video/mod.rs | 4 +- design-previews/ai-settings.html | 529 --- docs/tinyagents-migration-spec.md | 726 ++++ docs/tinyagents-sdk-gaps.md | 464 +++ examples/mouse_smoke.rs | 89 - .../developing/architecture/agent-harness.md | 125 + remotion/.gitignore | 9 - remotion/.prettierrc | 5 - remotion/README.md | 68 - remotion/eslint.config.mjs | 3 - remotion/package.json | 39 - remotion/pnpm-lock.yaml | 3280 ----------------- remotion/public/Boobateaholding.svg | 223 -- remotion/public/Crying.svg | 138 - remotion/public/Cupholding.svg | 187 - remotion/public/Laughing.svg | 138 - remotion/public/bigsmilewithblackcap.svg | 165 - remotion/public/celebrate.svg | 204 - remotion/public/hatwithbag.svg | 216 -- remotion/public/syicsmile.svg | 164 - remotion/public/wink.svg | 154 - remotion/remotion.config.ts | 11 - remotion/scripts/render-runtime-assets.mjs | 265 -- remotion/scripts/render-transparent.sh | 29 - remotion/src/Mascot/YellowMascotBumDance.tsx | 419 --- remotion/src/Mascot/lib/MascotCharacter.tsx | 586 --- remotion/src/Mascot/lib/index.ts | 1 - remotion/src/Mascot/lib/mascotPalette.ts | 73 - .../src/Mascot/mascot-black-celebrate.tsx | 355 -- remotion/src/Mascot/mascot-black-crying.tsx | 364 -- .../src/Mascot/mascot-black-hat-with-bag.tsx | 347 -- remotion/src/Mascot/mascot-black-idle.tsx | 286 -- remotion/src/Mascot/mascot-black-laughing.tsx | 236 -- .../src/Mascot/mascot-black-listening.tsx | 306 -- remotion/src/Mascot/mascot-black-love.tsx | 370 -- remotion/src/Mascot/mascot-black-pickup.tsx | 310 -- remotion/src/Mascot/mascot-black-sleep.tsx | 345 -- remotion/src/Mascot/mascot-black-talking.tsx | 300 -- remotion/src/Mascot/mascot-black-thinking.tsx | 330 -- remotion/src/Mascot/mascot-black-wave.tsx | 297 -- remotion/src/Mascot/mascot-black-wink.tsx | 314 -- .../Mascot/mascot-yellow-boba-tea-holding.tsx | 285 -- .../src/Mascot/mascot-yellow-book-reading.tsx | 325 -- .../src/Mascot/mascot-yellow-celebrate.tsx | 374 -- remotion/src/Mascot/mascot-yellow-crying.tsx | 371 -- .../src/Mascot/mascot-yellow-cup-holding.tsx | 314 -- .../src/Mascot/mascot-yellow-greeting.tsx | 22 - .../src/Mascot/mascot-yellow-hat-with-bag.tsx | 364 -- remotion/src/Mascot/mascot-yellow-idle.tsx | 10 - .../src/Mascot/mascot-yellow-laughing.tsx | 236 -- .../src/Mascot/mascot-yellow-listening.tsx | 349 -- remotion/src/Mascot/mascot-yellow-love.tsx | 401 -- remotion/src/Mascot/mascot-yellow-pickup.tsx | 45 - remotion/src/Mascot/mascot-yellow-sleep.tsx | 22 - .../src/Mascot/mascot-yellow-smile-slow.tsx | 233 -- remotion/src/Mascot/mascot-yellow-smile.tsx | 231 -- remotion/src/Mascot/mascot-yellow-talking.tsx | 16 - .../src/Mascot/mascot-yellow-thinking.tsx | 23 - .../src/Mascot/mascot-yellow-wave-alt.tsx | 444 --- remotion/src/Mascot/mascot-yellow-wave.tsx | 10 - remotion/src/Mascot/mascot-yellow-wink.tsx | 301 -- remotion/src/Root.tsx | 399 -- remotion/src/index.css | 1 - remotion/src/index.ts | 7 - remotion/tsconfig.json | 15 - scripts/test-rust-with-mock.sh | 4 + src/core/observability.rs | 20 - src/openhuman/agent/bus.rs | 41 +- src/openhuman/agent/harness/agent_graph.rs | 128 + .../agent/harness/archivist/recap.rs | 2 +- src/openhuman/agent/harness/bughunt_tests.rs | 579 --- .../agent/harness/builtin_definitions.rs | 3 + src/openhuman/agent/harness/definition.rs | 24 +- .../agent/harness/definition_tests.rs | 5 +- .../agent/harness/engine/checkpoint.rs | 17 - src/openhuman/agent/harness/engine/core.rs | 1174 ------ .../agent/harness/engine/core_tests.rs | 1005 ----- src/openhuman/agent/harness/engine/mod.rs | 35 +- src/openhuman/agent/harness/engine/parser.rs | 70 - .../agent/harness/engine/progress.rs | 127 - src/openhuman/agent/harness/engine/state.rs | 98 - .../agent/harness/engine/tool_source.rs | 150 - src/openhuman/agent/harness/engine/tools.rs | 537 --- src/openhuman/agent/harness/fork_context.rs | 31 +- src/openhuman/agent/harness/graph.rs | 246 ++ .../agent/harness/harness_gap_tests.rs | 270 -- src/openhuman/agent/harness/interrupt.rs | 166 - src/openhuman/agent/harness/mod.rs | 24 +- .../agent/harness/model_vision_context.rs | 75 - .../agent/harness/payload_summarizer.rs | 6 +- src/openhuman/agent/harness/self_healing.rs | 290 -- .../agent/harness/session/agent_tool_exec.rs | 4 +- .../agent/harness/session/builder/factory.rs | 35 +- .../agent/harness/session/builder/setters.rs | 75 +- src/openhuman/agent/harness/session/mod.rs | 1 - .../agent/harness/session/turn/core.rs | 712 ++-- .../agent/harness/session/turn/graph.rs | 107 + .../agent/harness/session/turn/mod.rs | 1 + .../harness/session/turn_engine_adapter.rs | 561 --- src/openhuman/agent/harness/session/types.rs | 8 - src/openhuman/agent/harness/session_queue.rs | 158 - .../harness/subagent_runner/ops/graph.rs | 783 ++++ .../harness/subagent_runner/ops/loop_.rs | 274 -- .../agent/harness/subagent_runner/ops/mod.rs | 17 +- .../harness/subagent_runner/ops/observer.rs | 236 -- .../harness/subagent_runner/ops/runner.rs | 145 +- .../subagent_runner/ops/tool_source.rs | 135 - .../harness/subagent_runner/ops/usage.rs | 14 + .../harness/subagent_runner/ops_tests.rs | 31 +- .../agent/harness/subagent_runner/types.rs | 8 +- src/openhuman/agent/harness/test_support.rs | 755 ---- .../agent/harness/test_support_tests.rs | 1757 --------- src/openhuman/agent/harness/tests.rs | 139 - src/openhuman/agent/harness/token_budget.rs | 696 ---- src/openhuman/agent/harness/tool_loop.rs | 709 ---- .../agent/harness/tool_loop_tests.rs | 2190 ----------- src/openhuman/agent/library/ops.rs | 1 + src/openhuman/agent/stop_hooks.rs | 33 +- src/openhuman/agent/tests.rs | 8 +- src/openhuman/agent_memory/agent/graph.rs | 13 + src/openhuman/agent_memory/agent/mod.rs | 1 + .../agent_orchestration/agent_teams/graph.rs | 294 ++ .../agent_orchestration/agent_teams/mod.rs | 2 + .../agent_teams/runtime.rs | 216 +- .../agent_orchestration/delegation.rs | 165 + src/openhuman/agent_orchestration/mod.rs | 1 + .../agent_orchestration/ops_tests.rs | 50 +- .../agent_orchestration/running_subagents.rs | 165 +- src/openhuman/agent_orchestration/tools.rs | 3 + .../tools/delegate_graph.rs | 164 + .../tools/spawn_parallel_agents.rs | 52 +- .../workflow_runs/engine.rs | 594 +-- .../workflow_runs/engine_tests.rs | 5 +- .../workflow_runs/graph.rs | 222 ++ .../agent_orchestration/workflow_runs/mod.rs | 1 + .../agents/account_admin_agent/graph.rs | 13 + .../agents/account_admin_agent/mod.rs | 1 + .../agent_registry/agents/archivist/graph.rs | 13 + .../agent_registry/agents/archivist/mod.rs | 1 + .../agents/code_executor/graph.rs | 13 + .../agents/code_executor/mod.rs | 1 + .../agents/context_scout/graph.rs | 13 + .../agents/context_scout/mod.rs | 1 + .../agent_registry/agents/critic/graph.rs | 13 + .../agent_registry/agents/critic/mod.rs | 1 + .../agents/crypto_agent/graph.rs | 13 + .../agent_registry/agents/crypto_agent/mod.rs | 1 + .../agents/desktop_control_agent/graph.rs | 13 + .../agents/desktop_control_agent/mod.rs | 1 + .../agents/goals_agent/graph.rs | 13 + .../agent_registry/agents/goals_agent/mod.rs | 1 + .../agent_registry/agents/help/graph.rs | 13 + .../agent_registry/agents/help/mod.rs | 1 + .../agents/image_agent/graph.rs | 13 + .../agent_registry/agents/image_agent/mod.rs | 1 + .../agents/integrations_agent/graph.rs | 13 + .../agents/integrations_agent/mod.rs | 1 + src/openhuman/agent_registry/agents/loader.rs | 48 + .../agents/markets_agent/graph.rs | 13 + .../agents/markets_agent/mod.rs | 1 + .../agent_registry/agents/mcp_agent/graph.rs | 13 + .../agent_registry/agents/mcp_agent/mod.rs | 1 + .../agent_registry/agents/mcp_setup/graph.rs | 13 + .../agent_registry/agents/mcp_setup/mod.rs | 1 + .../agents/morning_briefing/graph.rs | 13 + .../agents/morning_briefing/mod.rs | 1 + .../agents/orchestrator/graph.rs | 13 + .../agent_registry/agents/orchestrator/mod.rs | 1 + .../agent_registry/agents/planner/graph.rs | 13 + .../agent_registry/agents/planner/mod.rs | 1 + .../agents/presentation_agent/graph.rs | 13 + .../agents/presentation_agent/mod.rs | 1 + .../agents/profile_memory_agent/graph.rs | 13 + .../agents/profile_memory_agent/mod.rs | 1 + .../agent_registry/agents/researcher/graph.rs | 13 + .../agent_registry/agents/researcher/mod.rs | 1 + .../agents/scheduler_agent/graph.rs | 13 + .../agents/scheduler_agent/mod.rs | 1 + .../agents/screen_awareness_agent/graph.rs | 13 + .../agents/screen_awareness_agent/mod.rs | 1 + .../agents/settings_agent/graph.rs | 13 + .../agents/settings_agent/mod.rs | 1 + .../agents/skill_creator/graph.rs | 13 + .../agents/skill_creator/mod.rs | 1 + .../agent_registry/agents/summarizer/graph.rs | 13 + .../agent_registry/agents/summarizer/mod.rs | 1 + .../agents/task_manager_agent/graph.rs | 13 + .../agents/task_manager_agent/mod.rs | 1 + .../agent_registry/agents/tool_maker/graph.rs | 13 + .../agent_registry/agents/tool_maker/mod.rs | 1 + .../agents/tools_agent/graph.rs | 13 + .../agent_registry/agents/tools_agent/mod.rs | 1 + .../agents/trigger_reactor/graph.rs | 13 + .../agents/trigger_reactor/mod.rs | 1 + .../agents/trigger_triage/graph.rs | 13 + .../agents/trigger_triage/mod.rs | 1 + .../agents/video_agent/graph.rs | 13 + .../agent_registry/agents/video_agent/mod.rs | 1 + .../agents/vision_agent/graph.rs | 13 + .../agent_registry/agents/vision_agent/mod.rs | 1 + .../channels/runtime/dispatch/mod.rs | 1 + src/openhuman/config/schema/learning.rs | 20 - .../config/schema/load/env_overlay.rs | 8 - src/openhuman/context/README.md | 16 +- src/openhuman/context/manager.rs | 215 +- src/openhuman/context/manager_tests.rs | 420 +-- src/openhuman/context/mod.rs | 9 +- .../context/segment_recap_summarizer.rs | 226 -- .../context/segment_recap_summarizer_tests.rs | 572 --- src/openhuman/context/summarizer.rs | 593 --- src/openhuman/context/summarizer_tests.rs | 383 -- src/openhuman/cost/catalog.rs | 84 + src/openhuman/mod.rs | 1 + src/openhuman/model_council/council.rs | 17 +- src/openhuman/model_council/graph.rs | 274 ++ src/openhuman/model_council/mod.rs | 1 + src/openhuman/session_db/run_ledger/store.rs | 19 +- .../skill_registry/agent/skill_setup/graph.rs | 13 + .../skill_registry/agent/skill_setup/mod.rs | 1 + .../agent/skill_executor/graph.rs | 13 + .../skill_runtime/agent/skill_executor/mod.rs | 1 + src/openhuman/subconscious/agent/graph.rs | 13 + src/openhuman/subconscious/agent/mod.rs | 1 + src/openhuman/tinyagents/checkpoint.rs | 251 ++ src/openhuman/tinyagents/convert.rs | 494 +++ src/openhuman/tinyagents/delegation.rs | 444 +++ src/openhuman/tinyagents/middleware.rs | 1119 ++++++ src/openhuman/tinyagents/mod.rs | 632 ++++ src/openhuman/tinyagents/model.rs | 479 +++ src/openhuman/tinyagents/observability.rs | 421 +++ src/openhuman/tinyagents/orchestration.rs | 261 ++ src/openhuman/tinyagents/stop_hooks.rs | 149 + src/openhuman/tinyagents/summarize.rs | 241 ++ src/openhuman/tinyagents/tests.rs | 405 ++ src/openhuman/tinyagents/tools.rs | 431 +++ src/openhuman/tinyagents/topology.rs | 106 + src/openhuman/tinyplace/agent/graph.rs | 13 + src/openhuman/tinyplace/agent/mod.rs | 1 + src/openhuman/tool_timeout/mod.rs | 37 + src/openhuman/tools/ops.rs | 5 + src/openhuman/tools/orchestrator_tools.rs | 1 + src/openhuman/workflows/e2e_plumbing_tests.rs | 30 +- src/openhuman/workflows/e2e_run_tests.rs | 28 +- tests/agent_harness_public.rs | 31 +- tests/inference_agent_raw_coverage_e2e.rs | 16 +- 253 files changed, 11431 insertions(+), 31553 deletions(-) rename {remotion/public => app/src-tauri/src/fake_camera}/mascot.svg (100%) rename {remotion/public => app/src-tauri/src/meet_video}/Bookreading.svg (100%) rename {remotion/public => app/src-tauri/src/meet_video}/idelMascot.svg (100%) delete mode 100644 design-previews/ai-settings.html create mode 100644 docs/tinyagents-migration-spec.md create mode 100644 docs/tinyagents-sdk-gaps.md delete mode 100644 examples/mouse_smoke.rs delete mode 100644 remotion/.gitignore delete mode 100644 remotion/.prettierrc delete mode 100644 remotion/README.md delete mode 100644 remotion/eslint.config.mjs delete mode 100644 remotion/package.json delete mode 100644 remotion/pnpm-lock.yaml delete mode 100644 remotion/public/Boobateaholding.svg delete mode 100644 remotion/public/Crying.svg delete mode 100644 remotion/public/Cupholding.svg delete mode 100644 remotion/public/Laughing.svg delete mode 100644 remotion/public/bigsmilewithblackcap.svg delete mode 100644 remotion/public/celebrate.svg delete mode 100644 remotion/public/hatwithbag.svg delete mode 100644 remotion/public/syicsmile.svg delete mode 100644 remotion/public/wink.svg delete mode 100644 remotion/remotion.config.ts delete mode 100644 remotion/scripts/render-runtime-assets.mjs delete mode 100755 remotion/scripts/render-transparent.sh delete mode 100644 remotion/src/Mascot/YellowMascotBumDance.tsx delete mode 100644 remotion/src/Mascot/lib/MascotCharacter.tsx delete mode 100644 remotion/src/Mascot/lib/index.ts delete mode 100644 remotion/src/Mascot/lib/mascotPalette.ts delete mode 100644 remotion/src/Mascot/mascot-black-celebrate.tsx delete mode 100644 remotion/src/Mascot/mascot-black-crying.tsx delete mode 100644 remotion/src/Mascot/mascot-black-hat-with-bag.tsx delete mode 100644 remotion/src/Mascot/mascot-black-idle.tsx delete mode 100644 remotion/src/Mascot/mascot-black-laughing.tsx delete mode 100644 remotion/src/Mascot/mascot-black-listening.tsx delete mode 100644 remotion/src/Mascot/mascot-black-love.tsx delete mode 100644 remotion/src/Mascot/mascot-black-pickup.tsx delete mode 100644 remotion/src/Mascot/mascot-black-sleep.tsx delete mode 100644 remotion/src/Mascot/mascot-black-talking.tsx delete mode 100644 remotion/src/Mascot/mascot-black-thinking.tsx delete mode 100644 remotion/src/Mascot/mascot-black-wave.tsx delete mode 100644 remotion/src/Mascot/mascot-black-wink.tsx delete mode 100644 remotion/src/Mascot/mascot-yellow-boba-tea-holding.tsx delete mode 100644 remotion/src/Mascot/mascot-yellow-book-reading.tsx delete mode 100644 remotion/src/Mascot/mascot-yellow-celebrate.tsx delete mode 100644 remotion/src/Mascot/mascot-yellow-crying.tsx delete mode 100644 remotion/src/Mascot/mascot-yellow-cup-holding.tsx delete mode 100644 remotion/src/Mascot/mascot-yellow-greeting.tsx delete mode 100644 remotion/src/Mascot/mascot-yellow-hat-with-bag.tsx delete mode 100644 remotion/src/Mascot/mascot-yellow-idle.tsx delete mode 100644 remotion/src/Mascot/mascot-yellow-laughing.tsx delete mode 100644 remotion/src/Mascot/mascot-yellow-listening.tsx delete mode 100644 remotion/src/Mascot/mascot-yellow-love.tsx delete mode 100644 remotion/src/Mascot/mascot-yellow-pickup.tsx delete mode 100644 remotion/src/Mascot/mascot-yellow-sleep.tsx delete mode 100644 remotion/src/Mascot/mascot-yellow-smile-slow.tsx delete mode 100644 remotion/src/Mascot/mascot-yellow-smile.tsx delete mode 100644 remotion/src/Mascot/mascot-yellow-talking.tsx delete mode 100644 remotion/src/Mascot/mascot-yellow-thinking.tsx delete mode 100644 remotion/src/Mascot/mascot-yellow-wave-alt.tsx delete mode 100644 remotion/src/Mascot/mascot-yellow-wave.tsx delete mode 100644 remotion/src/Mascot/mascot-yellow-wink.tsx delete mode 100644 remotion/src/Root.tsx delete mode 100644 remotion/src/index.css delete mode 100644 remotion/src/index.ts delete mode 100644 remotion/tsconfig.json create mode 100644 src/openhuman/agent/harness/agent_graph.rs delete mode 100644 src/openhuman/agent/harness/bughunt_tests.rs delete mode 100644 src/openhuman/agent/harness/engine/core.rs delete mode 100644 src/openhuman/agent/harness/engine/core_tests.rs delete mode 100644 src/openhuman/agent/harness/engine/parser.rs delete mode 100644 src/openhuman/agent/harness/engine/state.rs delete mode 100644 src/openhuman/agent/harness/engine/tool_source.rs delete mode 100644 src/openhuman/agent/harness/engine/tools.rs create mode 100644 src/openhuman/agent/harness/graph.rs delete mode 100644 src/openhuman/agent/harness/interrupt.rs delete mode 100644 src/openhuman/agent/harness/model_vision_context.rs delete mode 100644 src/openhuman/agent/harness/self_healing.rs create mode 100644 src/openhuman/agent/harness/session/turn/graph.rs delete mode 100644 src/openhuman/agent/harness/session/turn_engine_adapter.rs delete mode 100644 src/openhuman/agent/harness/session_queue.rs create mode 100644 src/openhuman/agent/harness/subagent_runner/ops/graph.rs delete mode 100644 src/openhuman/agent/harness/subagent_runner/ops/loop_.rs delete mode 100644 src/openhuman/agent/harness/subagent_runner/ops/observer.rs delete mode 100644 src/openhuman/agent/harness/subagent_runner/ops/tool_source.rs create mode 100644 src/openhuman/agent/harness/subagent_runner/ops/usage.rs delete mode 100644 src/openhuman/agent/harness/test_support.rs delete mode 100644 src/openhuman/agent/harness/test_support_tests.rs delete mode 100644 src/openhuman/agent/harness/token_budget.rs delete mode 100644 src/openhuman/agent/harness/tool_loop.rs delete mode 100644 src/openhuman/agent/harness/tool_loop_tests.rs create mode 100644 src/openhuman/agent_memory/agent/graph.rs create mode 100644 src/openhuman/agent_orchestration/agent_teams/graph.rs create mode 100644 src/openhuman/agent_orchestration/delegation.rs create mode 100644 src/openhuman/agent_orchestration/tools/delegate_graph.rs create mode 100644 src/openhuman/agent_orchestration/workflow_runs/graph.rs create mode 100644 src/openhuman/agent_registry/agents/account_admin_agent/graph.rs create mode 100644 src/openhuman/agent_registry/agents/archivist/graph.rs create mode 100644 src/openhuman/agent_registry/agents/code_executor/graph.rs create mode 100644 src/openhuman/agent_registry/agents/context_scout/graph.rs create mode 100644 src/openhuman/agent_registry/agents/critic/graph.rs create mode 100644 src/openhuman/agent_registry/agents/crypto_agent/graph.rs create mode 100644 src/openhuman/agent_registry/agents/desktop_control_agent/graph.rs create mode 100644 src/openhuman/agent_registry/agents/goals_agent/graph.rs create mode 100644 src/openhuman/agent_registry/agents/help/graph.rs create mode 100644 src/openhuman/agent_registry/agents/image_agent/graph.rs create mode 100644 src/openhuman/agent_registry/agents/integrations_agent/graph.rs create mode 100644 src/openhuman/agent_registry/agents/markets_agent/graph.rs create mode 100644 src/openhuman/agent_registry/agents/mcp_agent/graph.rs create mode 100644 src/openhuman/agent_registry/agents/mcp_setup/graph.rs create mode 100644 src/openhuman/agent_registry/agents/morning_briefing/graph.rs create mode 100644 src/openhuman/agent_registry/agents/orchestrator/graph.rs create mode 100644 src/openhuman/agent_registry/agents/planner/graph.rs create mode 100644 src/openhuman/agent_registry/agents/presentation_agent/graph.rs create mode 100644 src/openhuman/agent_registry/agents/profile_memory_agent/graph.rs create mode 100644 src/openhuman/agent_registry/agents/researcher/graph.rs create mode 100644 src/openhuman/agent_registry/agents/scheduler_agent/graph.rs create mode 100644 src/openhuman/agent_registry/agents/screen_awareness_agent/graph.rs create mode 100644 src/openhuman/agent_registry/agents/settings_agent/graph.rs create mode 100644 src/openhuman/agent_registry/agents/skill_creator/graph.rs create mode 100644 src/openhuman/agent_registry/agents/summarizer/graph.rs create mode 100644 src/openhuman/agent_registry/agents/task_manager_agent/graph.rs create mode 100644 src/openhuman/agent_registry/agents/tool_maker/graph.rs create mode 100644 src/openhuman/agent_registry/agents/tools_agent/graph.rs create mode 100644 src/openhuman/agent_registry/agents/trigger_reactor/graph.rs create mode 100644 src/openhuman/agent_registry/agents/trigger_triage/graph.rs create mode 100644 src/openhuman/agent_registry/agents/video_agent/graph.rs create mode 100644 src/openhuman/agent_registry/agents/vision_agent/graph.rs delete mode 100644 src/openhuman/context/segment_recap_summarizer.rs delete mode 100644 src/openhuman/context/segment_recap_summarizer_tests.rs delete mode 100644 src/openhuman/context/summarizer.rs delete mode 100644 src/openhuman/context/summarizer_tests.rs create mode 100644 src/openhuman/model_council/graph.rs create mode 100644 src/openhuman/skill_registry/agent/skill_setup/graph.rs create mode 100644 src/openhuman/skill_runtime/agent/skill_executor/graph.rs create mode 100644 src/openhuman/subconscious/agent/graph.rs create mode 100644 src/openhuman/tinyagents/checkpoint.rs create mode 100644 src/openhuman/tinyagents/convert.rs create mode 100644 src/openhuman/tinyagents/delegation.rs create mode 100644 src/openhuman/tinyagents/middleware.rs create mode 100644 src/openhuman/tinyagents/mod.rs create mode 100644 src/openhuman/tinyagents/model.rs create mode 100644 src/openhuman/tinyagents/observability.rs create mode 100644 src/openhuman/tinyagents/orchestration.rs create mode 100644 src/openhuman/tinyagents/stop_hooks.rs create mode 100644 src/openhuman/tinyagents/summarize.rs create mode 100644 src/openhuman/tinyagents/tests.rs create mode 100644 src/openhuman/tinyagents/tools.rs create mode 100644 src/openhuman/tinyagents/topology.rs create mode 100644 src/openhuman/tinyplace/agent/graph.rs diff --git a/.github/workflows/pr-ci.yml b/.github/workflows/pr-ci.yml index a257e6f06..9a3b9f104 100644 --- a/.github/workflows/pr-ci.yml +++ b/.github/workflows/pr-ci.yml @@ -403,6 +403,9 @@ jobs: run: bash scripts/ci-cancel-aware.sh cargo llvm-cov --no-fail-fast -p openhuman --lcov --output-path lcov-core.info env: CARGO_BUILD_JOBS: "1" + # The tinyagents harness is the agent engine on every build now + # (issue #4249); the suite exercises it by default. The legacy engine + # is being removed. - name: Prune coverage test executables before cache save if: always() diff --git a/Cargo.lock b/Cargo.lock index f5fa32f25..a92c3bbfe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5263,6 +5263,7 @@ dependencies = [ "tar", "tempfile", "thiserror 2.0.18", + "tinyagents", "tinyplace", "tokio", "tokio-rustls", @@ -7966,6 +7967,22 @@ dependencies = [ "crunchy", ] +[[package]] +name = "tinyagents" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ca5080fa45dc248b4c3fcee368891a240ea4f8103243ca581202ab17bb881c7" +dependencies = [ + "async-trait", + "futures", + "reqwest 0.12.28", + "serde", + "serde_json", + "sha2 0.10.9", + "thiserror 2.0.18", + "tokio", +] + [[package]] name = "tinyplace" version = "1.0.1" diff --git a/Cargo.toml b/Cargo.toml index b9560beaf..3964c23de 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,6 +40,19 @@ crate-type = ["rlib"] [dependencies] # tiny.place A2A social network SDK — published on crates.io (tinyhumansai/tiny.place) tinyplace = "1.0.1" +# TinyAgents — Rust LLM orchestration framework (LangGraph/LangChain-style): +# durable state graphs, agent-loop harness, model/tool registries, REPL + +# `.rag` workflow language. openhuman's agent engine + orchestration run on this +# crate's primitives via the adapter seam in `src/openhuman/tinyagents/` (issue +# #4249): every turn drives through the harness; the workflow phase DAG, team +# member runtime, parallel fan-out, and multi-stage delegation run on graphs. +# Default (offline) features only — we wire openhuman's own Provider/Tool, not +# the bundled openai client. We deliberately do NOT enable the `sqlite` feature: +# its `rusqlite 0.40` (libsqlite3-sys 0.38) conflicts with openhuman's own +# `rusqlite 0.37` over the `links = "sqlite3"` native lib. Durable graph +# checkpoints are instead provided by `SqlRunLedgerCheckpointer`, a custom +# `Checkpointer` over openhuman's SQLite (see `src/openhuman/tinyagents/checkpoint.rs`). +tinyagents = "1.2" # TokenJuice code compressor — AST-aware signature extraction. Optional (C build) # behind the default `tokenjuice-treesitter` feature; disabling it falls back to # the language-agnostic brace-depth heuristic. See src/openhuman/tokenjuice/compressors/code.rs. diff --git a/app/src-tauri/Cargo.lock b/app/src-tauri/Cargo.lock index 443e6ddbf..1cdabf23f 100644 --- a/app/src-tauri/Cargo.lock +++ b/app/src-tauri/Cargo.lock @@ -5518,6 +5518,7 @@ dependencies = [ "tar", "tempfile", "thiserror 2.0.18", + "tinyagents", "tinyplace", "tokio", "tokio-rustls", @@ -8928,6 +8929,22 @@ dependencies = [ "strict-num", ] +[[package]] +name = "tinyagents" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ca5080fa45dc248b4c3fcee368891a240ea4f8103243ca581202ab17bb881c7" +dependencies = [ + "async-trait", + "futures", + "reqwest 0.12.28", + "serde", + "serde_json", + "sha2 0.10.9", + "thiserror 2.0.18", + "tokio", +] + [[package]] name = "tinyplace" version = "1.0.1" diff --git a/remotion/public/mascot.svg b/app/src-tauri/src/fake_camera/mascot.svg similarity index 100% rename from remotion/public/mascot.svg rename to app/src-tauri/src/fake_camera/mascot.svg diff --git a/app/src-tauri/src/fake_camera/mod.rs b/app/src-tauri/src/fake_camera/mod.rs index 2c4ac01d3..13160685d 100644 --- a/app/src-tauri/src/fake_camera/mod.rs +++ b/app/src-tauri/src/fake_camera/mod.rs @@ -30,7 +30,7 @@ const FRAMERATE: &str = "F30:1"; /// Mascot SVG embedded at build time. The remotion bundle owns the /// canonical asset; we vendor a copy of its content via `include_str!` /// so the shell builds without needing the remotion tree at runtime. -const MASCOT_SVG: &str = include_str!("../../../../remotion/public/mascot.svg"); +const MASCOT_SVG: &str = include_str!("mascot.svg"); /// Top-level entrypoint. Returns the path to a Y4M file CEF can read, /// rasterizing the mascot if no cached version exists. diff --git a/remotion/public/Bookreading.svg b/app/src-tauri/src/meet_video/Bookreading.svg similarity index 100% rename from remotion/public/Bookreading.svg rename to app/src-tauri/src/meet_video/Bookreading.svg diff --git a/remotion/public/idelMascot.svg b/app/src-tauri/src/meet_video/idelMascot.svg similarity index 100% rename from remotion/public/idelMascot.svg rename to app/src-tauri/src/meet_video/idelMascot.svg diff --git a/app/src-tauri/src/meet_video/mod.rs b/app/src-tauri/src/meet_video/mod.rs index 314551b54..f25fb9d9e 100644 --- a/app/src-tauri/src/meet_video/mod.rs +++ b/app/src-tauri/src/meet_video/mod.rs @@ -49,13 +49,13 @@ pub mod inject; /// Idle mascot SVG (calm, eyes-forward). Rasterized into the canvas /// during the bridge's `ready` promise. -const MASCOT_IDLE_SVG: &str = include_str!("../../../../remotion/public/idelMascot.svg"); +const MASCOT_IDLE_SVG: &str = include_str!("idelMascot.svg"); /// Thinking mascot SVG (book-reading pose) — toggled in/out as the /// agent's "thinking" state. Picked over `Cupholding`/`syicsmile` for /// the most legible mood difference; revisit when phase 2 swaps the /// static SVG for a live Remotion-driven OSR feed. -const MASCOT_THINKING_SVG: &str = include_str!("../../../../remotion/public/Bookreading.svg"); +const MASCOT_THINKING_SVG: &str = include_str!("Bookreading.svg"); /// Bridge JS template. Two `__OPENHUMAN_MASCOT_*_DATAURI__` tokens are /// substituted at install time with base64'd SVG data URIs. diff --git a/design-previews/ai-settings.html b/design-previews/ai-settings.html deleted file mode 100644 index 60f90ba55..000000000 --- a/design-previews/ai-settings.html +++ /dev/null @@ -1,529 +0,0 @@ - - - - - - OpenHuman · AI settings (preview) - - - - - - - - - - - - - - - -
- -
-
- - -

AI

- preview -
-
- -
- -
-
-

Cloud providers

- -
- -
- -
-
- - -
-

Local provider

- -
-
- - - -
-
running
-
ollama · v0.3.14
-
- - -
- -
    - -
-
- -
-
-
- - -
-
-

Workload routing

-
- - - -
-
- -
-
-
Chat
-
- -
-
- -
-
Background
-
- -
-
-
- -
- Primary resolves to - -
-
- - -
-
-
- -
-
-
- - unsaved change -
-
-
- - -
-
-
-
- - - - diff --git a/docs/tinyagents-migration-spec.md b/docs/tinyagents-migration-spec.md new file mode 100644 index 000000000..db35c502d --- /dev/null +++ b/docs/tinyagents-migration-spec.md @@ -0,0 +1,726 @@ +# TinyAgents Migration Spec + +Status: draft migration backlog + +TinyAgents source reviewed: `tinyhumansai/tinyagents` `origin/main` at +`8f226f1`, crate version `1.1.0`. + +OpenHuman already depends on `tinyagents = "1.1"` and already routes the live +agent turn through `src/openhuman/tinyagents/`. This spec is not a proposal to +add TinyAgents. It is a todo list for moving the rest of OpenHuman's generic +agent runtime behavior onto TinyAgents primitives while keeping OpenHuman-owned +product semantics in OpenHuman. + +## Goal + +Use TinyAgents as the generic runtime for: + +- model/provider abstraction and model selection +- tools and tool schemas +- middleware around model/tool calls +- streaming, events, traces, and replayable run status +- token usage and cost rollups +- state graphs, fanout, reducers, checkpoints, and interrupts +- sub-agent recursion, steering, cancellation, and reusable sessions +- deterministic testkit coverage for the runtime seams + +OpenHuman should continue to own: + +- desktop product UX and Tauri/RPC boundaries +- user/workspace config, credentials, keychain, and approval records +- OpenHuman memory stores, thread transcripts, run ledgers, and controllers +- security policy, sandboxing, tool permission tiers, and workspace roots +- product-specific built-in agents, prompts, MCP setup, Composio, channels, and + native tools +- compatibility with existing JSON-RPC method names and persisted state + +## Sources Reviewed + +TinyAgents SDK: + +- `src/lib.rs` +- `src/harness/*` +- `src/graph/*` +- `src/registry/*` +- `src/language/*` +- `src/repl/*` +- `docs/modules/harness/*.md` +- `docs/modules/graph/*.md` +- `docs/modules/registry/*.md` +- `docs/modules/expressive-language/README.md` +- `docs/modules/repl-language/README.md` +- examples: `agent_loop_tools`, `orchestrator_subagents`, `durable_graph`, + `openai_graph_agent`, `openai_self_blueprint` + +OpenHuman Rust core: + +- `src/openhuman/tinyagents/*` +- `src/openhuman/agent/**` +- `src/openhuman/agent_orchestration/**` +- `src/openhuman/agent_registry/**` +- `src/openhuman/tools/**` +- `src/openhuman/inference/**` +- `src/openhuman/cost/**` +- `src/openhuman/context/**` +- `src/openhuman/approval/**` +- `src/openhuman/security/**` +- `src/openhuman/mcp_registry/**` +- `src/core/event_bus/**` +- `src/core/all.rs` +- `gitbooks/developing/architecture/agent-harness.md` + +## Current Adoption Inventory + +Already done or partially done: + +- `Cargo.toml` pins `tinyagents = "1.1"` with default features only. +- `src/openhuman/tinyagents/mod.rs` registers OpenHuman `Provider` and `Tool` + adapters on `tinyagents::harness::runtime::AgentHarness`. +- `ProviderModel` maps OpenHuman `ChatRequest`/`ChatResponse` into + `tinyagents::harness::model::{ModelRequest, ModelResponse, ModelStream}`. +- `ToolAdapter` and `SharedToolAdapter` map OpenHuman tools into + `tinyagents::harness::tool::Tool`. +- `OpenhumanEventBridge` maps TinyAgents `AgentEvent` into `AgentProgress` and + the global cost tracker. +- `StopHookMiddleware`, `ContextCompressionMiddleware`, and + `MessageTrimMiddleware` are already used on the TinyAgents path. +- `SqlRunLedgerCheckpointer` implements TinyAgents `Checkpointer` on top of the + OpenHuman session DB because TinyAgents' `sqlite` feature conflicts with the + current `rusqlite` native-link version. +- `run_parallel_fanout` uses `GraphBuilder`, reducers, command routing, and a + fan-in barrier for reusable concurrent fanout. +- `model_council`, `workflow_runs`, `agent_teams`, and + `tinyagents/delegation.rs` already use TinyAgents graphs. +- Built-in agents already have `graph.rs` selectors, but most return + `AgentGraph::Default`. + +Important current gaps: + +- OpenHuman still has separate registries for agents, tools, MCP tools, model + providers, controllers, cost, and event bus projections. +- Tool safety metadata exists in OpenHuman traits but is not fully expressed as + TinyAgents tool safety/runtime metadata. +- Cost/usage is still converted through a bridge, not an end-to-end TinyAgents + usage/cost journal. +- Event streams are mirrored into `AgentProgress`, but TinyAgents event journals + and status stores are not the canonical durable inspection surface. +- Provider model profiles, model resolution, and fallback remain primarily in + OpenHuman inference/router logic. +- Sub-agent lifecycle, durable state, worker threads, and wait/abort controls + still live in OpenHuman orchestration stores. + +Some todos below are local adapter work. Others require upstream TinyAgents SDK +extensions first. In particular, the SDK has a strong tool/runtime boundary +today (`ToolSchema`, `ToolExecutionContext`, middleware hooks), but OpenHuman's +full tool safety metadata is richer than the current SDK schema. Durable task +storage is another SDK gap: TinyAgents exposes an `InMemoryTaskStore`, while +OpenHuman needs restart-safe SQL/JSON ledgers. + +## Migration Rules + +- Keep every product-facing JSON-RPC contract stable unless a migration plan is + written next to the code change. +- Do not bypass OpenHuman approval, security policy, sandbox, workspace root, or + credential boundaries by adopting a generic TinyAgents tool API. +- Prefer adapters first, then flip ownership once tests prove parity. +- Preserve existing transcript and run-ledger compatibility. TinyAgents may + become the internal runtime without changing persisted public records in the + same PR. +- Every migration task needs unit coverage plus at least one JSON-RPC or + harness-level e2e when behavior crosses controller, tool, provider, or graph + boundaries. + +## Phase 0 - Baseline And Drift Control + +- [x] Add a version/feature compatibility note to the OpenHuman architecture doc. + - OpenHuman files: `gitbooks/developing/architecture/agent-harness.md`, + `Cargo.toml`. + - TinyAgents components: crate features `default`, `openai`, `sqlite`, `repl`. + - Acceptance: document why default features are used, why TinyAgents `sqlite` + is disabled, and which OpenHuman adapters replace feature-gated SDK + providers. + - **Done:** added "TinyAgents crate: features & compatibility" section to + agent-harness.md (default-only, `openai`/`sqlite`/`repl` rationale, adapter + map) + fixed stale `council_graph.rs`/`member_graph.rs` links. + +## Phase 1 - Tools + +- [~] Make OpenHuman tool metadata round-trip into TinyAgents tool metadata. + - OpenHuman files: `src/openhuman/tools/traits.rs`, + `src/openhuman/tinyagents/tools.rs`, `src/openhuman/tinyagents/convert.rs`. + - TinyAgents components: `harness::tool::{ToolSchema, ToolFormat, + ToolExecutionContext, ToolResult}`. + - Migrate: permission level, external effect, generated runtime context, + timeout policy, concurrency safety, result-size cap, display label/detail, + markdown support. + - Acceptance: a TinyAgents tool call has enough metadata for middleware to + enforce approval, security, timeout, concurrency, truncation, and display + behavior without re-querying OpenHuman trait methods ad hoc. + - **SDK gap + side-lookup adapter:** crate `ToolSchema` carries only + name/description/parameters/format — it has **no** metadata/extension map and + `ToolExecutionContext` is run-scoped, so none of OpenHuman's safety/runtime + fields have a crate home. The adopted pattern is a shared + `name → Arc` lookup the runner builds from `tool_sets`; middleware + calls the (often args-aware) trait methods live. Landed uses of it: + `ApprovalSecurityMiddleware` reads `external_effect_with_args`; + `ToolOutputMiddleware` now honors each tool's own `max_result_size_chars()` + (was a flat hardcoded budget). Remaining fields (timeout, concurrency, + display, generated context) can ride the same lookup as their middlewares + land; full crate round-trip is blocked pending an SDK `ToolSchema` extension + map. + +- [x] Move unknown-tool recovery into a reusable middleware or tool policy layer. + - Current shim: `UNKNOWN_TOOL_SENTINEL` in `src/openhuman/tinyagents/tools.rs`. + - TinyAgents components: `ToolRegistry`, `ToolMiddleware`, + `AgentEvent::ToolStarted/ToolCompleted`, repairable tool results. + - Acceptance: hallucinated tool names remain recoverable, sub-agent wording is + preserved, and TinyAgents event stream records the original requested tool + name without exposing the sentinel as a model-visible tool. + - **Done:** `UnknownToolRewriteMiddleware` (`before_tool`) rewrites a call to + an unadvertised tool onto the sentinel at the tool boundary, before the + harness resolves it — removing the `valid_tools` plumbing from `ProviderModel` + (`with_valid_tools`, the field, and the `response_to_model_response` rewrite + all deleted). Sub-agent vs top-level wording is preserved by the sentinel + handler; the sentinel is still never advertised. SDK gaps: no + "tool-not-found → repair" hook (the sentinel handler must stay), and no + dedicated unknown-tool `AgentEvent` variant (recording the original name in + the crate event stream would need a manual `ToolStarted` — deferred). + +- [x] Route approval and security through TinyAgents middleware. + - Current OpenHuman files: `src/openhuman/approval/*`, + `src/openhuman/security/*`, `src/openhuman/tinyagents/tools.rs`. + - TinyAgents components: `ToolMiddleware`, `ToolExecutionContext`, + tool safety metadata. + - Acceptance: approval checks happen in `before_tool`/`wrap_tool`, emit typed + events, preserve audit rows, and return model-consumable denial results. + - **Done:** `ApprovalSecurityMiddleware` (`tinyagents/middleware.rs`, a + `wrap_tool` middleware) replaces the inline approval block in + `execute_openhuman_tool`. Denials short-circuit with a model-consumable + result; approved external-effect calls now record a terminal audit row + (`record_execution`) the old path dropped. Typed approval events still ride + `DomainEvent` (the crate `AgentEvent` enum has no approval variant — SDK gap). + Tool-*internal* security (path/command `live_policy`) stays per-tool by + design. Follow-ups: channel permission-ceiling threading; per-tool metadata + side-lookup (Task C). + +- [ ] Use TinyAgents bounded-concurrent tool execution where safe. + - OpenHuman files: `src/openhuman/tools/traits.rs`, + `src/openhuman/tinyagents/tools.rs`. + - TinyAgents components: graph `Send`, graph fanout, or harness tool + execution policy. + - Acceptance: read-only/concurrency-safe tool batches can run in parallel + with deterministic result ordering and identical transcript semantics. + +## Phase 2 - Models And Providers + +- [ ] Register OpenHuman inference providers as TinyAgents model registry entries. + - OpenHuman files: `src/openhuman/inference/provider/*`, + `src/openhuman/inference/model_ids.rs`, `src/openhuman/tinyagents/model.rs`. + - TinyAgents components: `harness::model::{ChatModel, ModelRegistry, + ModelProfile, CapabilitySet, ModelRequest, ModelResponse}`. + - Acceptance: every workload route (`agentic`, `reasoning`, `coding`, + `memory`, `subconscious`, etc.) can resolve to a TinyAgents model entry + while retaining OpenHuman provider strings and config compatibility. + +- [ ] Translate OpenHuman provider capability data into TinyAgents model profiles. + - OpenHuman files: `src/openhuman/inference/provider/traits.rs`, + `src/openhuman/inference/provider/factory.rs`, + `docs/inference-provider-catalog.md`. + - TinyAgents components: `ModelProfile`, `CapabilitySet`, registry model + catalog. + - Acceptance: context window, tool calling, streaming, vision, structured + output, reasoning, local/cloud source, and provider-family metadata are + available before dispatch. + +- [ ] Move model fallback and retry policy to TinyAgents policy/middleware. + - OpenHuman files: `src/openhuman/inference/provider/reliable.rs`, + `src/openhuman/inference/provider/router.rs`, + `src/openhuman/tinyagents/mod.rs`. + - TinyAgents components: `RunPolicy`, `RetryPolicy`, `FallbackPolicy`, + `ModelFallbackMiddleware`, `AgentEvent::RetryScheduled`, + `AgentEvent::FallbackSelected`. + - Acceptance: OpenHuman provider retry does not double-retry under + TinyAgents, fallback events are typed, and tests cover transient 429/5xx, + config rejection, and billing exhaustion. + +- [ ] Preserve provider-specific metadata in the TinyAgents message model. + - OpenHuman files: `src/openhuman/inference/provider/traits.rs`, + `src/openhuman/tinyagents/convert.rs`, + `src/openhuman/tinyagents/model.rs`. + - TinyAgents components: `ContentBlock`, `AssistantMessage`, provider + metadata, tool-call ids. + - Acceptance: Gemini thought signatures, reasoning content, native tool-call + ids, cached tokens, and raw provider metadata survive multi-turn history. + +## Phase 3 - Middleware + +- [ ] Convert OpenHuman turn cross-cuts into named TinyAgents middleware. + - Current OpenHuman surfaces: stop hooks, approval gate, security policy, + output caps, context compression, memory injection, tool allowlists, + cost/usage, prompt cache stability, event bridge. + - TinyAgents components: `Middleware`, `ModelMiddleware`, `ToolMiddleware`, + `MiddlewareStack`, `RunContext`. + - Acceptance: each cross-cut has a stable middleware name, tests for ordering, + emitted events, and explicit interaction with streaming/retry/fallback. + +- [ ] Add OpenHuman policy middleware for dynamic tool exposure. + - OpenHuman files: `src/openhuman/agent_registry/*`, + `src/openhuman/agent/harness/subagent_runner/**`, + `src/openhuman/tools/user_filter.rs`. + - TinyAgents components: `before_model`, `before_tool`, `ToolRegistry`. + - Acceptance: agent `tool_allowlist`, `tool_denylist`, sub-agent tool scope, + MCP tool visibility, and channel permission ceilings are enforced through + middleware rather than scattered pre-filtering. + +- [ ] Add prompt/cache-layout middleware tests. + - OpenHuman files: `src/openhuman/context/*`, + `src/openhuman/agent/harness/session/turn/core.rs`, + `src/openhuman/tinyagents/summarize.rs`. + - TinyAgents components: `CachePolicy`, `CacheLayoutEvent`, + `ContextCompressionMiddleware`, `MessageTrimMiddleware`. + - Acceptance: system prompt prefix remains stable across later turns; volatile + memory, timestamps, tool results, and steering messages land in the tail. + +## Phase 4 - Events, Status, And Observability + +- [ ] Make TinyAgents event journals the canonical internal run event stream. + - OpenHuman files: `src/openhuman/tinyagents/observability.rs`, + `src/core/event_bus/*`, `src/openhuman/notifications/*`, + `src/openhuman/session_db/run_ledger/*`. + - TinyAgents components: `HarnessEventJournal`, `HarnessStatusStore`, + `GraphEventJournal`, `GraphStatusStore`, `AgentEvent`, `GraphEvent`. + - Acceptance: UIs can reconstruct a running or completed agent turn from + persisted TinyAgents events without relying only on transient + `AgentProgress`. + +- [ ] Bridge TinyAgents events into `DomainEvent` as a compatibility projection. + - OpenHuman files: `src/core/event_bus/events.rs`, + `src/openhuman/agent/bus.rs`, `src/openhuman/tinyagents/observability.rs`. + - Acceptance: existing subscribers continue to receive `DomainEvent`, but new + code reads TinyAgents events/status first. + +- [ ] Persist graph run status and checkpoint metadata in OpenHuman run ledger. + - OpenHuman files: `src/openhuman/tinyagents/checkpoint.rs`, + `src/openhuman/session_db/run_ledger/store.rs`, + `src/openhuman/agent_orchestration/**`. + - TinyAgents components: `Checkpoint`, `CheckpointMetadata`, + `GraphRunStatus`, `GraphObservation`. + - Acceptance: command center, workflow runs, delegation, and team runs can + list checkpoints, current node/task status, and replay offsets from one DB + source. + +- [~] Export graph topology for debugging and UI inspection. + - OpenHuman files: built-in `graph.rs` files, `agent_orchestration/*/graph.rs`, + `model_council/graph.rs`. + - TinyAgents components: `GraphTopology`, `to_json`, `to_mermaid`, + validation report. + - Acceptance: every custom OpenHuman graph has a debug endpoint or test + snapshot that exports topology and validates missing nodes/routes. + - **Foundation landed:** new `tinyagents/topology.rs` — `GraphTopologyReport` + (mermaid + JSON + validation errors/warnings), `describe()`, and + `all_graph_topologies()`. Pattern: each graph exposes a `build_*_graph` + (structure) reused by both the runner and a `*_topology()` that builds it + with no-op stub closures and returns `CompiledGraph::topology()`. Done for + `agent_teams:member`. Follow-ups (same pattern): `delegation` (injected + `run_stage` — clean) and `workflow_runs` scheduler (needs a small refactor — + its node closures capture engine locals). Fan-outs (council, + `run_parallel_fanout`) are the dispatch→N→collect pattern, not a fixed + topology. Debug endpoint = call `all_graph_topologies()` (RPC wiring is a + thin follow-up). + +## Phase 5 - Usage, Cost, And Budgets + +- [~] Replace bridge-only usage accounting with TinyAgents usage records. + - OpenHuman files: `src/openhuman/cost/*`, + `src/openhuman/tinyagents/observability.rs`, + `src/openhuman/inference/provider/traits.rs`. + - TinyAgents components: `harness::usage::{Usage, UsageTotals}`, + `harness::cost::CostTotals`, `AgentEvent::UsageRecorded`. + - Acceptance: input, output, cached input, reasoning, image/audio, embedding, + tool/model call counts, and estimated/provider-reported source are recorded + in normalized records. + - **Partial (real bug fixed):** the bridge hardcoded `charged_amount_usd: 0.0` + and `ProviderModel` dropped cached tokens, so EVERY tinyagents turn recorded + **$0 cost**. Now `model.rs` carries `cached_input_tokens` via crate + `Usage.cache_read_tokens`, and the bridge estimates per-call cost from + catalogued per-MTok rates (`cost::catalog::estimate_cost_usd`). Remaining: + reasoning/image/audio/embedding token fields, model/tool call counts, and an + explicit estimated-vs-provider-charged `cost_source` tag on `TokenUsage` + (provider-charged preservation needs an out-of-band carry — crate `Usage` has + no USD field). + +- [~] Move budget checks to pre-call TinyAgents middleware. + - OpenHuman files: `src/openhuman/cost/tracker.rs`, + `src/openhuman/tinyagents/mod.rs`. + - TinyAgents components: `RunPolicy`, cost middleware, `before_model`, + `before_tool`. + - Acceptance: per-run, per-thread, daily, and monthly budgets can warn or + fail before spend where enough data exists, then reconcile after provider + usage is known. + - **Partial:** `CostBudgetMiddleware` (`before_model`) fails the run before a + model call when the global daily/monthly budget is already exceeded + (`CostTracker::check_budget`), and logs on the warning threshold. Self-gating + on `config.enabled`; previously daily/monthly enforcement was dormant on the + tinyagents path. Remaining: per-run/per-thread budgets (need new + `CostConfig` fields + thread-id threading into the runner) and projecting the + *next* call's cost pre-spend (needs an input-token estimate). + +- [ ] Add cost rollup across sub-agents and graphs. + - OpenHuman files: `src/openhuman/agent_orchestration/**`, + `src/openhuman/cost/global.rs`. + - TinyAgents components: run ids, parent/root run lineage, + `SubAgentStarted`, `SubAgentCompleted`, graph child runs. + - Acceptance: parent run totals include child agent/model/tool usage without + double counting dashboard totals. + +## Phase 6 - Graph Runtime And Orchestration + +- [ ] Convert remaining ad hoc control loops into explicit TinyAgents graphs. + - Candidate OpenHuman files: `src/openhuman/agent_orchestration/*`, + `src/openhuman/subconscious/*`, `src/openhuman/cron/*`, + `src/openhuman/learning/*`, `src/openhuman/tools/ops.rs`. + - TinyAgents components: `GraphBuilder`, `Command`, conditional routing, + reducers, `Send`, barriers, recursion policy. + - Acceptance: every long-running multi-step orchestration has named nodes, + route tests, recursion bounds, cancellation checks, and topology export. + +- [ ] Replace simple fanout helpers with graph `Send` where payload-specific + fanout matters. + - Current helper: `src/openhuman/tinyagents/orchestration.rs`. + - TinyAgents components: `Command::send`, `GraphInput`, `NodeContext::send_arg`. + - Acceptance: map-reduce style flows can schedule multiple invocations of the + same node with distinct payloads instead of materializing one node per item. + +- [ ] Make `spawn_parallel_agents` a first-class TinyAgents graph tool. + - Current OpenHuman files: + `src/openhuman/agent_orchestration/tools/spawn_parallel_agents.rs`, + `src/openhuman/tinyagents/orchestration.rs`, + `src/openhuman/agent_orchestration/worktree.rs`, + `src/openhuman/agent_orchestration/workflow_runs/engine.rs`. + - Current behavior: validates at least two tasks, checks parent context, + enforces `max_parallel_tools`, resolves `AgentDefinition`s, enforces the + parent `subagents.allowlist`, optionally creates per-worker git worktrees, + fans workers out through `run_parallel_fanout`, collects results in input + order, emits `DomainEvent` + `AgentProgress`, detects stale parent file + reads and cross-worker changed-file overlaps, and returns a structured + `parallel_agents` JSON payload. + - TinyAgents components: `GraphBuilder`, `Command`, `Send`, + `NodeContext::send_arg`, `ChannelSet`/reducers, `GraphEventSink`, + `SubAgentNode` or OpenHuman `run_subagent` adapter, `RecursionPolicy`, + `RunPolicy`, `CancellationToken`. + - Required migration shape: + - `validate` node: parse tasks, enforce min/max count, parent context, + allowlist, toolkit requirements, and worktree preflight. + - `dispatch` node: use `Send` to schedule one worker invocation per task + instead of generating one static worker node per task. + - `worker` node: run the OpenHuman sub-agent build pipeline with inherited + model/tool/security policy, optional `worktree_action_dir`, child task id, + and bounded turn/output budgets. + - `collect` reducer: aggregate successes, failures, elapsed time, + iterations, worktree status, changed files, and stale read markers in + deterministic task order. + - `finalize` node: emit compatibility `DomainEvent`/`AgentProgress` + projections, overlap warnings, and the existing JSON result shape. + - Acceptance: parallel agent runs are checkpointable, cancellable at graph + boundaries, bounded by parent policy, observable through TinyAgents graph + events/status, compatible with existing `spawn_parallel_agents` tool + output, and able to run edit-capable workers in isolated worktrees without + silently falling back to shared workspace. + +- [ ] Define parallel-agent ownership and scheduling policy explicitly. + - OpenHuman files: `src/openhuman/agent_registry/agents/orchestrator/prompt.md`, + `src/openhuman/agent_orchestration/tools/spawn_parallel_agents.rs`, + `src/openhuman/tools/traits.rs`. + - TinyAgents components: graph route metadata, `RunPolicy`, task metadata, + tool safety metadata. + - Required policy: + - The parent must provide disjoint ownership boundaries for write-capable + tasks, or the graph rejects/falls back to serial delegation. + - Read-only workers may share the parent workspace; write-capable workers + should request `isolation = "worktree"`. + - Parent and children inherit one root run id for cost/event rollups, but + each child gets its own task id and optional worker-thread id. + - A child cannot widen tools, model choice, sandbox mode, trusted roots, or + budget beyond the parent-granted policy. + - Cancellation, steering, and wait/collect must be delivered at graph or + harness safe boundaries only. + - Acceptance: the orchestrator can ask for parallelism without prompt-only + conventions; policy violations become structured graph/tool errors. + +- [ ] Make per-agent `graph.rs` selectors real customization points. + - OpenHuman files: `src/openhuman/agent_registry/agents/*/graph.rs`, + `src/openhuman/agent/harness/agent_graph.rs`. + - TinyAgents components: `CompiledGraph`, sub-agent nodes, graph testkit. + - Acceptance: at least three agents get bespoke graphs where useful: + orchestrator, researcher, and tool_maker are good first candidates. + +- [ ] Keep durable orchestration stores OpenHuman-owned until TinyAgents has a + durable `TaskStore`. + - Current OpenHuman files: `running_subagents.rs`, `workflow_runs`, + `agent_teams`, `command_center`, `subagent_sessions`. + - TinyAgents limitation: `InMemoryTaskStore` is useful for lifecycle shape but + not sufficient for desktop restart/resume. + - Acceptance: do not migrate durable SQL/JSON state to TinyAgents in-memory + task storage. Use TinyAgents task types as an adapter only. + +- [ ] Add graph interrupt/resume for human review points. + - OpenHuman files: `src/openhuman/approval/*`, + `src/openhuman/agent_orchestration/workflow_runs/*`, + `src/openhuman/tinyagents/delegation.rs`. + - TinyAgents components: `Interrupt`, `ResumeTarget`, `Command::resume`, + checkpoints. + - Acceptance: approval/review pauses are durable graph interrupts where the + run can resume from the exact checkpoint after user action. + +## Phase 7 - Sub-Agents, Steering, And Recursion + +- [ ] Represent `spawn_subagent`, `steer_subagent`, `wait_subagent`, and + follow-ups as TinyAgents steering commands plus OpenHuman projections. + - OpenHuman files: `src/openhuman/agent_orchestration/tools.rs`, + `src/openhuman/agent_orchestration/running_subagents.rs`, + `src/openhuman/agent/harness/subagent_runner/**`. + - TinyAgents components: `SteeringCommand`, `SteeringHandle`, + `SubAgentSession`, `SubAgentTool`, recursion depth events. + - Acceptance: mid-flight messages are delivered only at safe loop boundaries, + accepted/rejected steering emits events, and tool/model allowlists can only + narrow from parent policy. + +- [ ] Reconcile OpenHuman spawn depth with TinyAgents recursion policy. + - OpenHuman files: `src/openhuman/agent/harness/spawn_depth_context.rs`, + `src/openhuman/agent/harness/subagent_runner/**`. + - TinyAgents components: `RecursionPolicy`, `RecursionStack`, + `SubAgentDepth`. + - Acceptance: there is one authoritative recursion cap and one error shape, + with compatibility conversion for existing UI/JSON-RPC responses. + +- [ ] Keep OpenHuman's sub-agent build pipeline as product logic. + - Product-owned pieces: agent definition resolution, prompt assembly, memory + context, worker-thread mirroring, handoff cache, tool filtering, provider + routing, sandbox scope. + - TinyAgents components to adopt beneath it: `SubAgentSession`, + `ToolExecutionContext`, event lineage, cancellation, usage/cost rollup. + - Acceptance: use TinyAgents for execution and lineage without flattening + OpenHuman's agent registry semantics into generic SDK defaults. + +## Phase 8 - Registry And Capability Catalog + +- [ ] Build a `CapabilityRegistry` projection from OpenHuman registries. + - OpenHuman files: `agent_registry`, `tools`, `mcp_registry`, `inference`, + `cost`, `approval`, `security`, controller registry in `src/core/all.rs`. + - TinyAgents components: `CapabilityRegistry`, `ComponentId`, + `ComponentKind`, model catalog, graph/tool/agent/store/middleware entries. + - Acceptance: OpenHuman models, tools, agents, graphs, stores, and middleware + can be looked up through one policy-aware capability projection. + +- [ ] Add registry diagnostics for duplicate names and unsafe aliases. + - OpenHuman files: `src/openhuman/tools/generated.rs`, + `mcp_registry`, `composio`, `agent_registry`. + - TinyAgents components: component names, `ToolRegistry`, diagnostics. + - Acceptance: duplicate tool names, provider-specific aliases, MCP names, and + generated tool names fail closed before model dispatch. + +- [ ] Use TinyAgents model catalog shape for local provider catalog snapshots. + - OpenHuman files: `docs/inference-provider-catalog.md`, + `src/openhuman/inference/presets.rs`, + `src/openhuman/inference/provider/factory.rs`. + - TinyAgents components: registry model catalog, local snapshots, price and + context metadata. + - Acceptance: model picker, router, budget estimator, and capability filter + read one normalized catalog projection. + +## Phase 9 - Memory, Retrieval, Embeddings, And Context + +- [ ] Adapt OpenHuman memory/retrieval to TinyAgents retriever interfaces. + - OpenHuman files: `memory`, `memory_search`, `memory_tree`, + `agent/memory_loader.rs`, `context/*`. + - TinyAgents components: `EmbeddingModel`, `Retriever`, `VectorStore`, + `ScoredDoc`, context events. + - Acceptance: the agent harness can load retrieval context through a + TinyAgents retriever facade while OpenHuman stores remain authoritative. + +- [ ] Move context compaction provenance into TinyAgents events. + - OpenHuman files: `context/README.md`, `tinyagents/summarize.rs`, + `agent/harness/payload_summarizer.rs`. + - TinyAgents components: `SummaryRecord`, `Compressed` events, cache layout + events. + - Acceptance: every summary records source ids, before/after token estimates, + policy version, and whether stable prompt prefix was preserved. + +- [ ] Normalize embedding usage/cost records. + - OpenHuman files: `embeddings`, `memory_sync`, `cost`. + - TinyAgents components: embedding usage fields, model catalog pricing. + - Acceptance: embedding calls contribute usage and cost with provider/model, + dimensions, vector count, and source. + +## Phase 10 - Dead Code And TinyAgents Re-Expression Audit + +Do not delete these blindly. Treat this as an audit list for code that is dead, +vestigial, or generic runtime behavior now expressible through TinyAgents +harness/graph primitives. Delete only after call-site search, compatibility +assessment, and migration coverage are complete. + +- [ ] Audit `src/openhuman/agent/harness/engine/*`. + - Current role: surviving seams from the retired in-house turn loops: + `CheckpointStrategy`, `ProgressReporter`, and `TurnProgress`. + - TinyAgents expression: `RunPolicy`, `AgentEvent`, `GraphEvent`, + `EventSink`, `HarnessStatusStore`, cap/stop middleware. + - Candidate outcome: move max-iteration and progress projection into + TinyAgents middleware/events, then delete `engine/*` if no product-specific + compatibility seam remains. + +- [ ] Audit `src/openhuman/agent/harness/agent_graph.rs` and built-in + `agent_registry/agents/*/graph.rs` default selectors. + - Current role: per-agent graph hook, but most built-ins return + `AgentGraph::Default`. + - TinyAgents expression: a registry of `CompiledGraph`/graph factories keyed + by agent id, with default graph supplied by the runtime and custom graphs + registered only where they differ. + - Candidate outcome: replace dozens of boilerplate default `graph.rs` files + with registry defaults, keeping files only for agents with custom graphs. + +- [~] Audit stale architecture references to removed in-house graph/loop code. + - Current files: `gitbooks/developing/architecture/agent-harness.md`, + `src/openhuman/context/README.md`. + - Candidate stale names: `src/openhuman/agent_graph/`, `GraphBlueprint`, + `run_turn_engine`, `run_tool_call_loop`, `harness/tool_loop.rs`, old + context summarizer files. + - TinyAgents expression: link to live `src/openhuman/tinyagents/*`, + `GraphBuilder`, `AgentHarness`, `ContextCompressionMiddleware`, and + graph-export/status surfaces. + - Candidate outcome: move historical details into a short "pre-migration + history" appendix or remove them from active architecture docs. + - **Partial:** flagged the `agent-harness.md` `agent_graph`/`GraphBlueprint` + section as HISTORICAL-removed (strong inline callout pointing at the live + tinyagents surfaces); fixed `context/README.md` "Used by" line that still + referenced the deleted `reduce_before_call`/`ProviderSummarizer`/ + `SegmentRecapSummarizer`/`unified_compaction_enabled`. Remaining: a sweep of + code doc-comments across many `.rs` files that still name `run_turn_engine`, + `run_tool_call_loop`, `tool_loop.rs` (comments only — no behavior). + +- [ ] Audit `src/openhuman/context/{pipeline,guard,microcompact}.rs`. + - Current role: context stats/session-memory bookkeeping plus older + compaction concepts; live history reduction moved to + `ContextCompressionMiddleware` and `MessageTrimMiddleware`. + - TinyAgents expression: context-window middleware, `Compressed` events, + cache layout events, `SummaryRecord`, usage/context pressure status. + - Candidate outcome: keep only stats/session-memory state that remains + OpenHuman-specific; move compression policy/provenance into TinyAgents + middleware and delete unused reduction paths. + +- [ ] Audit `src/openhuman/agent/harness/payload_summarizer.rs`. + - Current role: oversized tool-result compression via a `summarizer` + sub-agent with a local circuit breaker. + - TinyAgents expression: `ToolMiddleware::after_tool`, + `ContextCompressionMiddleware`, `SummaryRecord`, tool artifact/result + compaction events. + - Candidate outcome: convert to middleware over TinyAgents tool results so + the summarizer is no longer a separate OpenHuman-only hook. + +- [ ] Audit `src/openhuman/agent_orchestration/running_subagents.rs`. + - Current role: bespoke live-task registry layered on TinyAgents + `InMemoryTaskStore`, plus watch channels, abort handles, tombstones, + task/session lookup, wait, steer, and cancel operations. + - TinyAgents expression: `TaskStore`, `SteeringCommand`, `SteeringHandle`, + `CancellationToken`, run tree/status store, durable OpenHuman ledger + projections. + - Candidate outcome: keep OpenHuman durable session/worker-thread records, + but collapse transient lifecycle mechanics into TinyAgents task/status + primitives once they can represent wait/steer/hard-abort needs. + +- [ ] Audit `src/openhuman/agent_orchestration/tools/spawn_parallel_agents.rs` + after the graph-tool migration. + - Current role: validation, preflight, fanout, worktree setup, event + projection, overlap detection, result formatting. + - TinyAgents expression: graph nodes (`validate`, `dispatch`, `worker`, + `collect`, `finalize`) with `Send` fanout and reducers. + - Candidate outcome: keep a thin tool wrapper that invokes the graph and + formats the existing JSON payload; move orchestration mechanics into the + graph module. + +- [ ] Audit hand-rolled `join_all` fanouts that are workflow orchestration, not + simple IO batching. + - Candidate files from current search: `src/openhuman/mcp_registry/registry.rs`, + `src/openhuman/learning/reflection.rs`, + `src/openhuman/inference/local/service/ollama_admin/diagnostics.rs`. + - TinyAgents expression: only migrate fanouts that need agent/run lineage, + checkpointing, policy, cancellation, or graph observability. Leave simple + independent IO probes as ordinary Rust concurrency. + - Candidate outcome: document why each fanout stays as `join_all` or move it + to `run_parallel_fanout` / graph `Send`. + +- [ ] Audit tool registry comments and docs that still describe retired + direct-loop behavior. + - Current files: `src/openhuman/tools/traits.rs`, + `src/openhuman/tools/README.md`, + `src/openhuman/agent/harness/session/turn/tools.rs`. + - TinyAgents expression: `ToolRegistry`, `ToolMiddleware`, graph tool nodes, + tool safety metadata, `ToolExecutionContext`. + - Candidate outcome: update comments to describe the TinyAgents execution + path and delete references to the retired serial `harness::tool_loop` + dispatcher once no code path uses it. + +## Phase 11 - Testing And Conformance + +- [ ] Create a focused parity test matrix for the current TinyAgents route. + - OpenHuman files: `src/openhuman/tinyagents/tests.rs`, + `tests/agent_harness_e2e.rs`, `tests/agent_tool_loop_raw_coverage_e2e.rs`. + - TinyAgents components: `AgentHarness`, `AgentEvent`, `ToolRegistry`, + `ModelRegistry`, `MessageTrimMiddleware`, `ContextCompressionMiddleware`. + - Acceptance: chat turn, channel turn, sub-agent turn, unknown tool recovery, + approval denial, streaming text, streaming tool args, reasoning deltas, + early-exit pause, model-call cap, and cost footer all have tests on the + TinyAgents path. + +- [ ] Add a TinyAgents adapter inventory test. + - OpenHuman files: `src/openhuman/tinyagents/mod.rs`, + `src/openhuman/tinyagents/tests.rs`. + - Acceptance: one test asserts that the shared runner registers model, tools, + middleware, event bridge, context compression, stop hooks, and unknown-tool + sentinel in the intended order. + +- [ ] Port behavior clusters to TinyAgents testkit. + - OpenHuman files: `src/openhuman/agent/harness/*_tests.rs`, + `tests/agent_*`. + - TinyAgents components: harness `testkit`, graph `assert_graph`, + `GraphEventRecorder`, mock models/tools. + - Acceptance: legacy assertions over loop wording are replaced by assertions + over TinyAgents events, checkpoints, graph metadata, and final transcript. + +- [ ] Add cross-module e2e tests for graph/sub-agent/model/tool composition. + - Candidate tests: workflow run with child sub-agents, delegation with review + loop, council fanout, MCP tool call, Composio approval denial, memory + retrieval plus summarization. + - Acceptance: tests cover default features, `openai`-feature compatible code + paths where available, and all-features where dependency constraints allow. + +- [ ] Add fuzz-style graph composition tests. + - TinyAgents components: graph testkit, reducers, command routing, subgraph + nodes, sub-agent fake nodes. + - Acceptance: generated small graphs cover direct edges, conditional routes, + `Send` fanout, joins, interrupts, recursion caps, and checkpoint resume. + +## Suggested Execution Order + +1. Confirm version/feature compatibility and SDK-extension gaps. +2. Move tool safety/approval/security into TinyAgents middleware. +3. Normalize usage/cost records and parent/child rollups. +4. Persist TinyAgents event/status journals to the OpenHuman run ledger. +5. Build the OpenHuman `CapabilityRegistry` projection. +6. Convert one high-value built-in agent to a bespoke TinyAgents graph. +7. Adapt memory/retrieval/context surfaces where the TinyAgents interfaces are + a good fit. +8. Audit and remove or re-express dead/vestigial runtime code through + TinyAgents harness/graph primitives. +9. Finish with parity, adapter-inventory, conformance, e2e, and fuzz-style tests. + +## Non-Goals For The First Migration Wave + +- Do not enable TinyAgents `sqlite` until the `rusqlite` native-link conflict is + solved. +- Do not replace OpenHuman's durable run ledgers with TinyAgents + `InMemoryTaskStore`. +- Do not expose TinyAgents' OpenAI provider directly to product code while + OpenHuman provider config, credentials, OAuth, and billing classification are + still the product source of truth. +- Do not remove `DomainEvent` until all existing subscribers have a TinyAgents + event/status replacement. diff --git a/docs/tinyagents-sdk-gaps.md b/docs/tinyagents-sdk-gaps.md new file mode 100644 index 000000000..c15d6a5a0 --- /dev/null +++ b/docs/tinyagents-sdk-gaps.md @@ -0,0 +1,464 @@ +# TinyAgents SDK Gaps + +This document lists TinyAgents SDK features that are missing or only partially +available from the perspective of migrating OpenHuman's Rust agent core onto +TinyAgents. + +Scope: + +- Source baseline: local TinyAgents checkout at `6f898fb`. +- OpenHuman evidence: `src/openhuman/tinyagents/*`, + `src/openhuman/agent/*`, `src/openhuman/cost/*`, and + `src/openhuman/tokenjuice/*`. +- This is not the OpenHuman migration plan. That plan lives in + `docs/tinyagents-migration-spec.md`. +- Items here are upstream TinyAgents implementation candidates. +- Tests should be implemented last, after the API and storage surfaces settle. + +## Executive Summary + +TinyAgents already has strong primitives for harness runs, graph execution, +middleware, event streams, model profiles, usage/cost accounting, checkpointers, +and sub-agent orchestration. The biggest remaining gaps are production-grade +policy metadata, durable orchestration stores, richer streaming events, +recoverable tool-call behavior, graph fanout ergonomics, and SDK-owned adapters +for the lifecycle controls OpenHuman currently implements around the SDK. + +OpenHuman can migrate more of `src/openhuman/agent/` if TinyAgents grows these +features: + +- Rich tool metadata for safety, permissions, timeouts, retries, idempotency, + side effects, workspace access, and approval requirements. +- A recoverable unknown-tool policy so invalid model tool calls do not always + abort the run. +- First-class reasoning and tool-call argument streaming events. +- Durable `TaskStore` and event/status stores with replay, lineage, cursors, + redaction, and cancellation semantics. +- Storage compatibility options for SQLite users that already depend on a + different `rusqlite` / `libsqlite3-sys` version. +- Higher-level map/reduce and parallel-agent orchestration helpers on top of + graph `Send`. +- Budget enforcement and provider/model catalog metadata that can drive + preflight, fallback, and reconciliation. +- Conformance suites for providers, tools, middleware, graph stores, and + checkpointers. + +## Backlog + +### 1. Rich Tool Policy Metadata + +Status: partially present. + +TinyAgents has `ToolSchema { name, description, parameters, format }` and +`ToolExecutionContext { run_id, thread_id, depth, max_turn_output_tokens, +events }`. That is enough for model-visible tool calls, but not enough for +OpenHuman's approval gate, command classifier, workspace policy, sandbox +handoff, or tool-result budgeting. + +OpenHuman currently keeps that metadata outside TinyAgents in domain tool +registries and adapters. That means the SDK cannot make fail-closed decisions +about whether a tool should be exposed, approved, retried, timed out, or allowed +to touch the filesystem/network. + +Implement: + +- Add SDK-owned tool metadata, probably `ToolPolicy` or `ToolSafety`. +- Represent side effects: `read_only`, `writes_files`, `network`, + `installs_dependencies`, `destructive`, `external_service`, `payment`. +- Represent runtime requirements: timeout, retry policy, idempotency, + cancellation behavior, sandbox mode, max result bytes, streaming support. +- Represent access requirements: workspace root policy, trusted roots, + credentials needed, user approval required, background-safe vs interactive. +- Add helper middleware for policy enforcement before model-visible exposure and + before execution. + +Acceptance criteria: + +- Callers can build a dynamic per-run tool set from policy metadata. +- Unknown or under-classified tools fail closed by default. +- Tool policy can be serialized for registry introspection and audit logs. +- Existing plain `ToolSchema` remains supported as the model-visible projection. + +### 2. Recoverable Unknown Tool Calls + +Status: missing. + +TinyAgents currently returns `TinyAgentsError::ToolNotFound` when the model calls +an unregistered tool. OpenHuman's legacy loop treated this as a recoverable tool +result and let the model correct itself. The TinyAgents adapter now rewrites +unknown calls to an internal `__openhuman_unknown_tool__` sentinel so the loop can +continue. + +Implement: + +- Add `UnknownToolPolicy`. +- Suggested variants: + - `Fail`: current behavior. + - `ReturnToolError`: inject a tool result with the original requested name. + - `Rewrite { tool_name }`: adapter-controlled compatibility mode. + - `RepairWithMiddleware`: allow a tool middleware to transform the call. +- Preserve the original requested tool name, original arguments, and model call + id in events and observations. + +Acceptance criteria: + +- OpenHuman can delete `UNKNOWN_TOOL_SENTINEL`. +- Harness events distinguish "tool not found" from "tool executed and failed". +- The policy can vary by run, sub-agent, or tool allowlist. + +### 3. Reasoning And Tool-Argument Streaming + +Status: partially present. + +TinyAgents has `MessageDelta { text, tool_call }`, and `ModelDelta` events carry +that delta. OpenHuman providers also emit reasoning/thinking deltas and +tool-call argument fragments. The current adapter uses an out-of-band +`ThinkingForwarder` because those provider deltas do not round-trip through the +TinyAgents stream in a UI-compatible way. + +Implement: + +- Extend streaming deltas with explicit channels: + - visible text delta + - reasoning/thinking delta + - tool call start + - tool call argument delta + - tool call completed/assembled + - provider metadata/raw event summary +- Keep channel semantics provider-neutral. +- Emit the same data through `AgentEvent`, `AgentObservation`, journals, and live + stream items. +- Attribute every delta to run id, model call id, optional thread id, parent run + id, and root run id. + +Acceptance criteria: + +- OpenHuman can delete `ThinkingForwarder`. +- UI consumers can render visible text, reasoning, and tool argument assembly + from TinyAgents events alone. +- Non-streaming providers can still emit post-hoc reasoning as one event. + +### 4. Durable Orchestration Task Store + +Status: partially present. + +TinyAgents defines a `TaskStore` trait and an `InMemoryTaskStore`. OpenHuman +still owns durable detached-sub-agent state, cancellation handles, wait/reuse +semantics, tombstones, and task lifecycle persistence around that store. + +Implement: + +- Add durable `TaskStore` implementations: + - JSONL append store. + - SQLite store behind a storage feature. + - Optional caller-supplied store adapter. +- Persist task spec, status, timestamps, result, error, parent/root run ids, + cancellation requests, timeouts, and control decisions. +- Add lifecycle history, not only latest state. +- Support replay/listing by parent run, root run, thread id, task kind, status, + and created-at window. + +Acceptance criteria: + +- A process restart does not lose detached or awaiting orchestration tasks. +- Supervisors can list, wait, cancel, kill, and inspect tasks through the SDK + store contract. +- OpenHuman can retire most bespoke task status/tombstone persistence in + `running_subagents.rs`. + +### 5. SQLite Storage Compatibility + +Status: partially present. + +TinyAgents has a `SqliteCheckpointer`, but enabling the `sqlite` feature pulls a +specific `rusqlite` / `libsqlite3-sys` version. OpenHuman already depends on a +different SQLite native-link version, so it cannot enable that feature and had +to implement `SqlRunLedgerCheckpointer`. + +Implement one or more compatibility paths: + +- Make SQLite support trait-first and allow external connection adapters. +- Provide a version-flexible storage layer, possibly via `sqlx` or a separate + crate feature matrix. +- Split schema helpers from dependency ownership so apps can create the tables + using their own SQLite connection. +- Expose a small `CheckpointStore` persistence trait below `Checkpointer`. + +Acceptance criteria: + +- Applications that already own SQLite can use TinyAgents durable checkpoints + without native-link conflicts. +- OpenHuman can replace `SqlRunLedgerCheckpointer` with an SDK-supported adapter + or a thin schema integration. +- Storage features remain opt-in and keep the default crate dependency-light. + +### 6. Production Event And Status Journals + +Status: partially present. + +TinyAgents has `HarnessEventJournal`, `StoreEventJournal`, `HarnessStatusStore`, +and `HarnessRunStatus`. OpenHuman still bridges TinyAgents events into its own +progress system, cost tracker, run ledger, and UI status stream. + +Implement: + +- Durable event journals with cursors, replay windows, filters, compaction, and + redaction hooks. +- Status stores with parent/root lineage, thread-scoped listing, phase details, + active tool/model call ids, usage totals, cost totals, and terminal summaries. +- Event filters for UI surfaces: text stream only, tool timeline, cost updates, + graph lifecycle, errors, task lifecycle. +- Redaction policies for prompts, tool args, tool results, PII, secrets, and + provider payloads. +- Stable event ids and offset semantics across process restarts. + +Acceptance criteria: + +- A UI can attach late and reconstruct a run without subscribing at start time. +- A supervisor can query every active descendant of a root run. +- OpenHuman event bridges become mostly format adapters, not state owners. + +### 7. Cost, Usage, And Budget Enforcement + +Status: partially present. + +TinyAgents has `Usage`, `UsageTotals`, `CostTotals`, and accounting middleware. +OpenHuman still owns richer budget behavior, global cost trackers, per-session +rollups, budget stop hooks, and token/cost dashboard data. + +Implement: + +- A budget middleware that can preflight, enforce, and reconcile costs. +- Per-run and recursive root-run budgets for input, output, cached input, + reasoning tokens, total tokens, and money. +- Distinguish provider-reported usage from estimated usage. +- Track cached-token pricing, reasoning pricing, embeddings, image/audio usage, + and tool/provider fees where present. +- Add budget events: preflight, reservation, spend, refund/reconcile, warn, + exceeded, blocked. + +Acceptance criteria: + +- A caller can stop a recursive harness/graph run when a root budget is + exhausted. +- Budget totals roll up from child/sub-agent runs without custom side channels. +- OpenHuman cost UI can read TinyAgents-normalized records or a thin projection. + +### 8. Model Catalog And Provider Resolution + +Status: partially present. + +TinyAgents has `ModelProfile`, including provider, model, modalities, tool +calling, streaming, structured output, reasoning, and token windows. OpenHuman +still has provider catalog logic and local model capability inference that drive +fallback, token budgeting, and routing. + +Implement: + +- SDK-owned model catalog snapshots with provider, model id, display name, + lifecycle status, context windows, modalities, streaming support, reasoning, + structured-output support, and pricing keys. +- Capability-driven model resolution: required capabilities, fallback chains, + local/cloud preferences, and provider health. +- Runtime profile discovery hooks for local models. +- Pricing table integration that maps `ModelProfile` to `CostTotals`. + +Acceptance criteria: + +- Model selection can be expressed in TinyAgents policy instead of + OpenHuman-only routing code. +- Fallback can reject models that lack required tool, vision, structured-output, + context-window, or reasoning capabilities. +- Token budgeting can use the resolved model's real context window. + +### 9. Dynamic Tool Exposure And Allowlist Policy + +Status: partially present. + +TinyAgents can run with a provided tool registry, but OpenHuman needs per-agent, +per-tier, per-sub-agent, and per-task allowlists. Tool visibility depends on +security tier, workspace roots, parent/child delegation policy, model +capabilities, and whether the run is background or interactive. + +Implement: + +- A tool selection middleware that receives run context, agent identity, task + kind, parent policy, and model profile. +- Allowlist/denylist composition with explicit inheritance rules. +- Explainable exposure decisions for audit/debugging. +- Fail-closed behavior when policy metadata is missing. + +Acceptance criteria: + +- Sub-agents inherit only the tools they are allowed to call. +- Tool exposure decisions are visible in run events or observations. +- OpenHuman can remove adapter-local allowlist enforcement from most call paths. + +### 10. Graph Fanout And Parallel Agent Ergonomics + +Status: partially present. + +TinyAgents graph has `Send`, `Command`, reducers, interrupts, parallel execution, +and max concurrency. OpenHuman still added `run_parallel_fanout` to provide an +ordered, bounded map/reduce helper for council runs and `spawn_parallel_agents`. + +Implement: + +- Add a generic SDK helper for parallel map/reduce: + - preserve input order + - limit concurrency + - collect per-item success/failure + - support cancellation + - support reducer updates + - support timeout per item and total timeout + - expose graph lifecycle events +- Add a higher-level parallel-agent builder: + - validate task specs + - dispatch workers through `Send` + - collect result envelopes + - merge usage/cost/events + - detect worker failure policy: fail-fast, collect-all, quorum, best-effort + +Acceptance criteria: + +- OpenHuman can delete most of `run_parallel_fanout` and use the SDK helper. +- `spawn_parallel_agents` can be expressed as graph configuration plus + OpenHuman policy adapters. +- Results remain deterministic in input order even when workers complete out of + order. + +### 11. Sub-Agent Steering, Waiting, And Reuse + +Status: partially present. + +TinyAgents has sub-agent and steering primitives, but OpenHuman still owns +session reuse, wait handles, detached run tracking, user-facing cancellation, +early-exit handling, and parent-child progress aggregation. + +Implement: + +- First-class detached sub-agent sessions. +- `wait`, `cancel`, `kill`, `resume`, `steer`, and `close` controls backed by + `TaskStore`. +- Reusable child sessions with explicit lifecycle state. +- Parent/root event correlation for every child run. +- Early-exit policy that can pause a run and surface a structured payload. + +Acceptance criteria: + +- Callers can spawn a detached child run, wait for it later, and survive process + restart if durable stores are configured. +- Parent and child usage/cost/events roll up without bespoke registries. +- OpenHuman can reduce `running_subagents.rs` to policy and UI projection code. + +### 12. Workspace Isolation And Sandbox Hooks + +Status: missing as an SDK-owned abstraction. + +OpenHuman has workspace/action-root policy, internal workspace protection, +trusted roots, worktree isolation, sandbox modes, and command permission tiers. +TinyAgents should not own OpenHuman's policy, but it needs generic hooks for +agents that run tools over real files or command executors. + +Implement: + +- A `WorkspaceIsolation` or `ExecutionEnvironment` interface. +- Hooks for preparing per-agent worktrees/sandboxes and cleaning them up. +- Tool execution context fields for workspace root, logical task root, sandbox + descriptor, and policy identity. +- Events for isolation setup, violation, cleanup, and failure. + +Acceptance criteria: + +- Parallel agents can run with isolated workspaces using SDK lifecycle hooks. +- Tools can discover their allowed root from context instead of app globals. +- Policy engines can block unsafe paths before tool execution. + +### 13. Middleware Control Outcomes + +Status: partially present. + +TinyAgents middleware is rich enough for wrapping model and tool calls, and the +graph layer has `Command` and `Interrupt`. Some OpenHuman behaviors still need +direct control outcomes: pause after early-exit tools, stop on budget, reroute +on fallback, and defer work to sub-agents. + +Implement: + +- Standard control outcomes from middleware: + - continue + - replace request/response + - retry + - fallback + - pause/interrupt + - stop with final response + - route/goto graph node + - defer to task/sub-agent +- Consistent event emission for each control outcome. +- Clear precedence when multiple middleware layers request control changes. + +Acceptance criteria: + +- Early-exit tools and budget stop hooks do not require adapter-local steering + side channels. +- Graph and harness middleware use compatible control vocabulary. +- Control decisions are visible in journals for audit/replay. + +### 15. Registry Diagnostics And Introspection + +Status: partially present. + +TinyAgents has registry primitives. OpenHuman still needs richer diagnostics for +duplicate components, alias resolution, component health, model/provider/tool +capabilities, and event listener wiring. + +Implement: + +- Registry snapshot export with models, tools, middleware, graph nodes, + checkpointers, task stores, event listeners, and aliases. +- Duplicate and shadowing diagnostics. +- Health/status probes for registered providers and stores. +- Machine-readable component dependency graph. +- Optional DOT/JSON graph export for runtime components, not only graph nodes. + +Acceptance criteria: + +- A CLI or UI can show exactly what TinyAgents components are active. +- Registry failures are actionable without inspecting app-specific logs. +- OpenHuman dead-code audits can map old modules to SDK-owned registry entries. + +### 17. Storage And Graph Conformance + +Status: missing as a standardized SDK suite. + +Durable graphs and task stores are hard to migrate safely without a shared +contract test suite. + +Implement: + +- Checkpointer conformance for memory, file, SQLite, and caller-supplied stores. +- TaskStore conformance for lifecycle transitions, filters, cancellation, + timeout, kill, restart/replay, and concurrent writes. +- Graph conformance for `Send`, reducers, interrupts, resume, max concurrency, + dynamic routing, fanout failure policy, and deterministic result collection. + +Acceptance criteria: + +- Storage adapters can be swapped without changing graph behavior. +- Durable interrupt/resume semantics are proven across backends. +- Parallel-agent helpers have regression tests for order, failure, timeout, and + cancellation. + +## Implementation Order + +1. Define API contracts for tool policy, unknown-tool handling, streaming delta + channels, durable task storage, storage adapters, and control outcomes. +2. Implement the lowest-level data types and traits behind non-breaking + defaults. +3. Add in-memory implementations first. +4. Add durable stores and compatibility adapters second. +5. Add middleware helpers and high-level graph helpers. +6. Migrate OpenHuman adapters to the new SDK surfaces. +7. Remove OpenHuman-specific compatibility shims once the SDK behavior is + equivalent. +8. Implement conformance and regression tests last. diff --git a/examples/mouse_smoke.rs b/examples/mouse_smoke.rs deleted file mode 100644 index e03665025..000000000 --- a/examples/mouse_smoke.rs +++ /dev/null @@ -1,89 +0,0 @@ -//! Manual smoke test for humanized MouseTool (#682). -//! -//! Run with: `cargo run --example mouse_smoke --release` -//! -//! Watch your cursor — it should curve, not teleport. Keep your hand -//! off the mouse during the run. - -use openhuman_core::openhuman::security::SecurityPolicy; -use openhuman_core::openhuman::tools::{MouseTool, Tool}; -use serde_json::json; -use std::sync::Arc; -use std::time::Instant; - -#[tokio::main] -async fn main() -> anyhow::Result<()> { - tracing_subscriber::fmt() - .with_env_filter( - tracing_subscriber::EnvFilter::try_from_default_env() - .unwrap_or_else(|_| "debug".into()), - ) - .init(); - - let policy = Arc::new(SecurityPolicy { - max_actions_per_hour: 1000, - ..SecurityPolicy::default() - }); - let tool = MouseTool::new(policy); - - println!("\n=== smoke 1: humanized move (default) ==="); - let t0 = Instant::now(); - let res = tool - .execute(json!({ "action": "move", "x": 800, "y": 500 })) - .await?; - println!("elapsed = {:?}", t0.elapsed()); - println!("result = {res:?}"); - assert!(!res.is_error, "humanized move should succeed"); - - tokio::time::sleep(std::time::Duration::from_millis(800)).await; - - println!("\n=== smoke 2: instant teleport (human_like=false) ==="); - let t0 = Instant::now(); - let res = tool - .execute(json!({ "action": "move", "x": 200, "y": 200, "human_like": false })) - .await?; - println!("elapsed = {:?}", t0.elapsed()); - println!("result = {res:?}"); - assert!(!res.is_error, "teleport move should succeed"); - - tokio::time::sleep(std::time::Duration::from_millis(800)).await; - - println!("\n=== smoke 3: humanized move long distance ==="); - let t0 = Instant::now(); - let res = tool - .execute(json!({ "action": "move", "x": 1400, "y": 800 })) - .await?; - println!("elapsed = {:?}", t0.elapsed()); - println!("result = {res:?}"); - assert!(!res.is_error); - - tokio::time::sleep(std::time::Duration::from_millis(800)).await; - - println!("\n=== smoke 4: humanized drag ==="); - let t0 = Instant::now(); - let res = tool - .execute(json!({ - "action": "drag", - "start_x": 600, "start_y": 400, - "x": 1000, "y": 600, - })) - .await?; - println!("elapsed = {:?}", t0.elapsed()); - println!("result = {res:?}"); - assert!(!res.is_error, "drag should succeed"); - - tokio::time::sleep(std::time::Duration::from_millis(800)).await; - - println!("\n=== smoke 5: humanized click ==="); - // Click in dead screen area to avoid collateral. - let t0 = Instant::now(); - let res = tool - .execute(json!({ "action": "click", "x": 50, "y": 50 })) - .await?; - println!("elapsed = {:?}", t0.elapsed()); - println!("result = {res:?}"); - assert!(!res.is_error); - - println!("\n✓ smoke complete — verify visually that motion was curved + paced for human-like runs and instant for the teleport."); - Ok(()) -} diff --git a/gitbooks/developing/architecture/agent-harness.md b/gitbooks/developing/architecture/agent-harness.md index d01a421ed..2ea3d71d0 100644 --- a/gitbooks/developing/architecture/agent-harness.md +++ b/gitbooks/developing/architecture/agent-harness.md @@ -7,6 +7,57 @@ icon: layer-group # Agent Harness +> **Status (issue #4249 — tinyagents migration):** the agent turn no longer runs +> on the in-tree `run_turn_engine` loop. **All three entry points (`Agent::turn`, +> the channel/CLI bus path, and `run_subagent`) now drive every turn through the +> published [`tinyagents`](https://crates.io/crates/tinyagents) 1.1 agent-loop +> harness** via the adapter seam in [`src/openhuman/tinyagents/`](../../../src/openhuman/tinyagents/) +> (`run_turn_via_tinyagents_shared`). The legacy `run_turn_engine`, the three +> hand-rolled loops, `turn_engine_adapter`, and the custom `agent_graph/` engine +> described later in this page have been **removed**; the surviving shared seams +> (`CheckpointStrategy`, `TurnProgress`) live in `agent/harness/engine/`. The dead +> `token_budget.rs` (context trimming is now `MessageTrimMiddleware`) and the +> vestigial `interrupt.rs` fence (cancellation is the tinyagents steering channel) +> are gone; policy **stop hooks** (budget / thread-goal / iteration caps) now fire +> through a `StopHookMiddleware` ([`tinyagents/stop_hooks.rs`](../../../src/openhuman/tinyagents/stop_hooks.rs)) +> that pauses the run on the first stop vote, and the channel route forwards live +> `AgentProgress` like the chat route. +> +> Multi-agent **orchestration** is expressed on tinyagents' **graph layer** via the +> shared helpers in [`tinyagents/orchestration.rs`](../../../src/openhuman/tinyagents/orchestration.rs) +> (`run_parallel_fanout` — a `dispatch → parallel workers → collect` `CompiledGraph` +> map step — plus the re-exported `graph::orchestration` `TaskStore` lifecycle +> primitives): +> +> - the model-council member fan-out runs on a real `StateGraph` +> ([`model_council/graph.rs`](../../../src/openhuman/model_council/graph.rs)); +> - [`tinyagents/delegation.rs`](../../../src/openhuman/tinyagents/delegation.rs) +> is a `plan → execute ⇄ review → finalize` `CompiledGraph` (conditional routing, +> `RecursionPolicy`, durable `FileCheckpointer`, `CancellationToken`, `GraphTracingSink`); +> - the **workflow phase engine** fans each phase's agents out on the graph +> (`with_max_concurrency`), keeping the durable `WorkflowRun` ledger as the resume +> source of truth; +> - `spawn_parallel_agents` runs its fan-out through `run_parallel_fanout`; +> - the **agent-teams** member runtime is a conditional-routing graph +> (`execute → complete | fail → done`, [`agent_teams/graph.rs`](../../../src/openhuman/agent_orchestration/agent_teams/graph.rs)); +> - the **detached-sub-agent** registry is backed by a typed `TaskStore` lifecycle +> ledger (Pending → Running → Completed/Failed/Cancelled). +> +> The sections below describing a bespoke `agent_graph/` module + per-agent +> `GraphBlueprint`s are **historical** (the pre-migration design) and are retained +> only for context. + +## TinyAgents crate: features & compatibility + +OpenHuman pins `tinyagents = "1.1"` with **default features only** (see [`Cargo.toml`](../../../Cargo.toml)). The rationale, so future upgrades don't silently regress it: + +- **Default (offline) features only.** We do **not** enable the crate's `openai` feature. OpenHuman owns provider transport, credentials, OAuth, and billing classification, so the live model is always OpenHuman's `Provider` wrapped as [`ProviderModel`](../../../src/openhuman/tinyagents/model.rs) — never the crate's bundled OpenAI client. The `ChatModel` adapter is the seam that replaces the feature-gated SDK provider. +- **`sqlite` feature deliberately disabled.** The crate's `SqliteCheckpointer` pulls `rusqlite 0.40` (`libsqlite3-sys 0.38`), which conflicts with OpenHuman's own `rusqlite 0.37` over the `links = "sqlite3"` native lib — enabling it breaks the build. Durable graph checkpoints are instead provided by [`SqlRunLedgerCheckpointer`](../../../src/openhuman/tinyagents/checkpoint.rs), a custom `Checkpointer` over OpenHuman's session DB. This holds until the upstream native-link conflict is resolved. +- **`repl` / expressive-language features unused.** OpenHuman drives graphs from Rust (`GraphBuilder`), not the crate's `.rag` REPL language. +- **Adapter map (feature-gated SDK piece → OpenHuman replacement):** OpenAI provider → `ProviderModel`; bundled SQLite checkpointer → `SqlRunLedgerCheckpointer`; in-memory-only durable task storage → OpenHuman SQL/JSON run ledgers (`running_subagents`, `workflow_runs`, `agent_teams`, `command_center`). The generic harness/graph/middleware/event primitives are used as-is. + +Migration backlog and per-phase tasks live in [`docs/tinyagents-migration-spec.md`](../../../docs/tinyagents-migration-spec.md). + The agent harness is the runtime that turns a user message (or a webhook fire, or a cron tick) into a complete, tool-using LLM interaction. It owns the tool-call loop, sub-agent dispatch, the trigger-triage pipeline, and the hook surface around them. It does **not** own provider HTTP transport, tool implementations, prompt-section assembly, or memory storage - those are separate domains the harness composes. This page walks through what happens in one turn, then zooms in on each of the moving parts. @@ -330,6 +381,80 @@ The harness lives entirely under `src/openhuman/agent/`. The README in that dire | `progress.rs` | Real-time progress events to the UI. | | `memory_loader.rs` | Memory-Tree context injection per user message. | +## Agent state graphs (`agent_graph`) — HISTORICAL (removed) + +> **⚠️ This section describes a design that was never shipped and has been removed.** +> The bespoke `src/openhuman/agent_graph/` engine, `GraphBlueprint`, and the +> `SqliteCheckpointer` described below **do not exist**. The live system runs on +> the published **tinyagents** crate — see the status banner at the top of this +> page and "Agent engine + orchestration on tinyagents (live)" below. Graphs are +> built with `tinyagents::graph::GraphBuilder` (`model_council/graph.rs`, +> `agent_orchestration/*/graph.rs`, `tinyagents/delegation.rs`), durable +> checkpoints use `SqlRunLedgerCheckpointer`, and per-agent graph selection is +> `AgentGraph` (`agent/harness/agent_graph.rs`) with each agent's +> `agent_registry/agents//graph.rs`. The text below is retained only as +> pre-migration design history. + +Alongside the linear tool-call loop, the harness ships a **LangGraph-style state-machine engine** under [`src/openhuman/agent_graph/`](../../../src/openhuman/agent_graph/) (issue #4249). Where the loop is an implicit "prompt → tool → result → next prompt" cycle, a graph models agent execution as an explicit directed graph of **nodes** (states) and **edges** (transitions), with typed working state that survives across transitions, parallel branches, and checkpoints. + +``` +StateGraph::new(name) + .add_node(id, node) // a unit of work: async fn(State) -> (State, Command) + .add_edge(from, to) // static transition + .add_conditional_edges(...) // route by inspecting state + .add_fork(from, [a, b]) // fan out in parallel; merge via State::merge + .set_entry_point(id) / .set_finish_point(id) + .compile()? -> CompiledGraph // validated; .invoke(state) / .resume_with(...) +``` + +| Subfolder | Role | +| ----------------- | ------------------------------------------------------------------------------------------------- | +| `graph/` | The engine: `GraphState` (merge reducer), `Node` trait, builder + `compile()` validation, Pregel super-step `executor` with cycle / cancel / step-cap guards, `invoke`/`resume`. | +| `checkpoint/` | `Checkpointer` trait (type-erased JSON state) → `InMemoryCheckpointer` (tests) + `SqliteCheckpointer` at `{workspace}/.openhuman/agent_graph/checkpoints.db`. Durable pause/resume. | +| `hitl/` | Human-in-the-loop: `approval`/`clarification` interrupt builders + `ApplyResume` (folds the human's answer into state on resume). A node returns `Command::Interrupt` to pause. | +| `observability/` | `EventBusSink` (a `ProgressSink`) emits `tracing` spans + publishes the `GraphRun*`/`GraphNode*` `DomainEvent` family (new `agent_graph` event domain). | +| `summarization/` | Node-boundary wrapper over `context::summarize_chat_history`. | +| `memory/` | Pre-node wrapper over `DefaultMemoryLoader::load_context`. | +| `definitions/` | Built-in graphs over a shared `ProductState`: `canonical_turn` (the agent turn as a `dispatch → parse → stop_check → tools → compact → loop / finalize` graph) and `plan_execute_review` (composes the `planner` + `code_executor` archetypes around a HITL review gate), plus a deterministic `demo_review` twin for tests. A registry (`list_definitions`/`build_definition`) + `runner` (`run_graph`/`resume_graph`) persist runs to the checkpointer and emit bus events. | +| `blueprint/` | The per-agent chain type. Every built-in agent declares its LangGraph-compatible chain in a `graph.rs` next to `prompt.rs` (`pub fn graph() -> GraphBlueprint`), wired into `BuiltinAgent.graph_fn`. `GraphBlueprint` is serializable (typed `NodeKind`/`EdgeSpec`), structurally validated, and `compile()`s to a real `CompiledGraph`. Reusable shapes: `canonical_turn` (most agents), `single_shot`, `orchestrator`, `plan_execute_review`. Inspect via `openhuman.agent_graph_{agent_list,agent_graph}`. | + +### Per-agent graphs (`graph.rs`) + +Each agent folder under `src/openhuman/agent_registry/agents//` (and the four agents that live in their own domains) now contains, alongside `agent.toml` + `prompt.rs`: + +- **`graph.rs`** — `pub fn graph() -> GraphBlueprint`. `prompt.rs` defines what the agent *says*; `graph.rs` defines how it *runs* — its node/edge chain. A loader test asserts **every** built-in agent's chain validates and compiles, so a malformed chain fails CI. + +Most agents reuse `blueprint::canonical_turn(id)` (the standard tool-calling loop); one-pass agents use `single_shot`, the orchestrator uses the delegation chain, and the planner uses `plan_execute_review`. + +**RPC surface** (`schemas.rs` + `ops.rs`, registered in `src/core/all.rs`): `openhuman.agent_graph_definition_list`, `_run`, `_run_list`, `_run_get`, `_checkpoint_list`, `_resume`. + +> **Status (issue #4249 — superseded by the published `tinyagents` crate):** the in-house `agent_graph` engine described in this section **no longer exists**. openhuman's agent engine + orchestration now run on the published [`tinyagents`](https://crates.io/crates/tinyagents) **1.1** crate (the same LangGraph-style harness + durable graph runtime), via the adapter seam in `src/openhuman/tinyagents/`. The sections above are retained as design history; the subsection below describes the live architecture. + +## Agent engine + orchestration on tinyagents (live) + +Every agent turn — chat (`session/turn/core.rs`), channel/CLI (`harness/channel_route.rs`), and sub-agent (`harness/subagent_runner/ops/graph_route.rs`) — drives through `crate::openhuman::tinyagents::run_turn_via_tinyagents_shared`, which runs the crate's `AgentHarness`. There is no in-house turn engine, tool loop, or routing gate left; dispatch is unconditional. The seam: + +| File (`src/openhuman/tinyagents/`) | Role | +| --- | --- | +| `mod.rs` | The runner (`run_turn_via_tinyagents_shared`): registers openhuman's `Provider`/`Tool` on an `AgentHarness`, runs one turn, caps output via `ProviderModel::with_max_tokens`, mirrors progress, forwards steering, and pauses gracefully at the model-call cap. | +| `model.rs` / `tools.rs` / `convert.rs` | `ChatModel` / `Tool` / message adapters (incl. out-of-band reasoning forwarding and unknown-tool recovery). | +| `observability.rs` | Harness `AgentEvent` → `AgentProgress` + cost; `GraphTracingSink` for graph events. | +| `orchestration.rs` | `run_parallel_fanout` (the shared `dispatch → workers → collect` graph) + re-exported `graph::orchestration` task-store types. | +| `checkpoint.rs` | `SqlRunLedgerCheckpointer` — a `Checkpointer` over openhuman's SQLite (`graph_checkpoints` table), since the crate's `SqliteCheckpointer` is dependency-blocked and it has no durable `TaskStore`. | +| `delegation.rs` | The durable `plan → execute ⇄ review → finalize` delegation graph (production worker wired in `agent_orchestration::delegation`). | + +**Orchestration on graphs** (`src/openhuman/agent_orchestration/`): + +- **Workflow phase DAG** (`workflow_runs/engine.rs`) runs on a `dispatch ⇄ run_phase → done` conditional-routing graph; each phase fans its agents out via `run_parallel_fanout`. The durable `workflow_runs` row stays the source of truth (controllers + resume read it). +- **Team member runtime** (`agent_teams/member_graph.rs`) is a conditional-routing graph (`execute → complete|fail → done`). +- **Multi-stage delegation** (`agent_orchestration::delegation` + the `delegate` tool) runs `delegation.rs`, checkpointed to the session DB. +- **Detached sub-agents** (`running_subagents.rs`) track lifecycle on the crate's `InMemoryTaskStore`; the executor (abort/steer/await) stays bespoke because the store can't inject messages, block-await, or hard-abort a task. + +**Deliberately kept off the crate's primitives** (documented engineering decisions, not gaps): + +- **Sub-agent build pipeline** (`subagent_runner/`) — definition resolution, archetype tool filtering, provider resolution, narrow prompt building, memory context, worker-thread mirror, handoff cache, checkpoint/resume — stays openhuman-owned. Sub-agents already *execute* on the harness; the crate's generic `SubAgentTool` would discard this pipeline for marginal crate-native depth tracking (openhuman's `spawn_depth_context` already bounds recursion). +- **Durable run ledgers** (`workflow_runs`, `agent_teams`, `command_center`, `subagent_sessions`) stay on openhuman SQLite/JSON: the crate's only `TaskStore` is in-memory, so moving them would lose durability and diverge from the controllers that read them. The `agent_teams` race-safe SQL compare-and-swap task claim has no crate equivalent. + ## See also * [Architecture overview](README.md) - where the harness sits in the bigger picture. diff --git a/remotion/.gitignore b/remotion/.gitignore deleted file mode 100644 index ed2e0ee45..000000000 --- a/remotion/.gitignore +++ /dev/null @@ -1,9 +0,0 @@ -node_modules -dist -.DS_Store -.env - -# Ignore the output video from Git but not videos you import into src/. -out - -build \ No newline at end of file diff --git a/remotion/.prettierrc b/remotion/.prettierrc deleted file mode 100644 index 37d507174..000000000 --- a/remotion/.prettierrc +++ /dev/null @@ -1,5 +0,0 @@ -{ - "useTabs": false, - "bracketSpacing": true, - "tabWidth": 2 -} diff --git a/remotion/README.md b/remotion/README.md deleted file mode 100644 index 39e1fc235..000000000 --- a/remotion/README.md +++ /dev/null @@ -1,68 +0,0 @@ -# Remotion video - -

- - - - Animated Remotion Logo - - -

- -Welcome to your Remotion project! - -## Commands - -**Install Dependencies** - -```console -pnpm install -``` - -**Start Preview** - -```console -pnpm dev -``` - -**Render a single variant** (produces `out/.mov` — transparent ProRes 4444) - -```console -pnpm render mascot-yellow-wave -``` - -**Render all variants** - -```console -pnpm render:all -``` - -**Render runtime mascot assets for the desktop app** (writes transparent animated WebP files for `yellow`, `burgundy`, `black`, `navy`, and `green` to `app/public/generated/remotion/`) - -> Requires a system `ffmpeg` binary on `PATH` for frame extraction. Install via `apt install ffmpeg`, `brew install ffmpeg`, or `choco install ffmpeg`. - -```console -pnpm render:runtime-assets -``` - -**Upgrade Remotion** - -```console -pnpm exec remotion upgrade -``` - -## Docs - -Get started with Remotion by reading the [fundamentals page](https://www.remotion.dev/docs/the-fundamentals). - -## Help - -We provide help on our [Discord server](https://discord.gg/6VzzNDwUwV). - -## Issues - -Found an issue with Remotion? [File an issue here](https://github.com/remotion-dev/remotion/issues/new). - -## License - -Note that for some entities a company license is needed. [Read the terms here](https://github.com/remotion-dev/remotion/blob/main/LICENSE.md). diff --git a/remotion/eslint.config.mjs b/remotion/eslint.config.mjs deleted file mode 100644 index 13b44a0d6..000000000 --- a/remotion/eslint.config.mjs +++ /dev/null @@ -1,3 +0,0 @@ -import { config } from "@remotion/eslint-config-flat"; - -export default config; diff --git a/remotion/package.json b/remotion/package.json deleted file mode 100644 index 0fdf44b47..000000000 --- a/remotion/package.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "name": "remotion", - "version": "1.0.0", - "description": "My Remotion video", - "repository": {}, - "license": "UNLICENSED", - "private": true, - "dependencies": { - "@remotion/cli": "4.0.454", - "@remotion/zod-types": "4.0.454", - "react": "19.2.3", - "react-dom": "19.2.3", - "remotion": "4.0.454", - "webp-converter": "^2.3.3", - "zod": "4.3.6", - "@remotion/tailwind-v4": "4.0.454", - "tailwindcss": "4.0.0" - }, - "devDependencies": { - "@remotion/eslint-config-flat": "4.0.454", - "@types/react": "19.2.7", - "@types/web": "0.0.166", - "eslint": "9.19.0", - "prettier": "3.8.1", - "typescript": "5.9.3" - }, - "scripts": { - "dev": "remotion studio", - "build": "remotion bundle", - "upgrade": "remotion upgrade", - "lint": "eslint src && tsc", - "render": "./scripts/render-transparent.sh", - "render:all": "./scripts/render-transparent.sh mascot-yellow-wave mascot-yellow-idle mascot-yellow-pickup mascot-yellow-talking mascot-yellow-thinking mascot-yellow-sleep", - "render:runtime-assets": "node ./scripts/render-runtime-assets.mjs" - }, - "sideEffects": [ - "*.css" - ] -} diff --git a/remotion/pnpm-lock.yaml b/remotion/pnpm-lock.yaml deleted file mode 100644 index ad54c480e..000000000 --- a/remotion/pnpm-lock.yaml +++ /dev/null @@ -1,3280 +0,0 @@ -lockfileVersion: '9.0' - -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false - -importers: - - .: - dependencies: - '@remotion/cli': - specifier: 4.0.454 - version: 4.0.454(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@remotion/tailwind-v4': - specifier: 4.0.454 - version: 4.0.454(@remotion/bundler@4.0.454(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(webpack@5.105.0) - '@remotion/zod-types': - specifier: 4.0.454 - version: 4.0.454(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(zod@4.3.6) - react: - specifier: 19.2.3 - version: 19.2.3 - react-dom: - specifier: 19.2.3 - version: 19.2.3(react@19.2.3) - remotion: - specifier: 4.0.454 - version: 4.0.454(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - tailwindcss: - specifier: 4.0.0 - version: 4.0.0 - webp-converter: - specifier: ^2.3.3 - version: 2.3.3 - zod: - specifier: 4.3.6 - version: 4.3.6 - devDependencies: - '@remotion/eslint-config-flat': - specifier: 4.0.454 - version: 4.0.454(eslint@9.19.0(jiti@2.6.1))(typescript@5.9.3) - '@types/react': - specifier: 19.2.7 - version: 19.2.7 - '@types/web': - specifier: 0.0.166 - version: 0.0.166 - eslint: - specifier: 9.19.0 - version: 9.19.0(jiti@2.6.1) - prettier: - specifier: 3.8.1 - version: 3.8.1 - typescript: - specifier: 5.9.3 - version: 5.9.3 - -packages: - - '@alloc/quick-lru@5.2.0': - resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} - engines: {node: '>=10'} - - '@babel/helper-string-parser@7.27.1': - resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} - engines: {node: '>=6.9.0'} - - '@babel/helper-validator-identifier@7.28.5': - resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} - engines: {node: '>=6.9.0'} - - '@babel/parser@7.24.1': - resolution: {integrity: sha512-Zo9c7N3xdOIQrNip7Lc9wvRPzlRtovHVE4lkz8WEDr7uYh/GMQhSiIgFxGIArRHYdJE5kxtZjAf8rT0xhdLCzg==} - engines: {node: '>=6.0.0'} - hasBin: true - - '@babel/types@7.24.0': - resolution: {integrity: sha512-+j7a5c253RfKh8iABBhywc8NSfP5LURe7Uh4qpsh6jc+aLJguvmIUBdjSdEMQv2bENrCR5MfRdjGo7vzS/ob7w==} - engines: {node: '>=6.9.0'} - - '@emnapi/core@1.10.0': - resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} - - '@emnapi/runtime@1.10.0': - resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} - - '@emnapi/wasi-threads@1.2.1': - resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} - - '@esbuild/aix-ppc64@0.25.0': - resolution: {integrity: sha512-O7vun9Sf8DFjH2UtqK8Ku3LkquL9SZL8OLY1T5NZkA34+wG3OQF7cl4Ql8vdNzM6fzBbYfLaiRLIOZ+2FOCgBQ==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [aix] - - '@esbuild/android-arm64@0.25.0': - resolution: {integrity: sha512-grvv8WncGjDSyUBjN9yHXNt+cq0snxXbDxy5pJtzMKGmmpPxeAmAhWxXI+01lU5rwZomDgD3kJwulEnhTRUd6g==} - engines: {node: '>=18'} - cpu: [arm64] - os: [android] - - '@esbuild/android-arm@0.25.0': - resolution: {integrity: sha512-PTyWCYYiU0+1eJKmw21lWtC+d08JDZPQ5g+kFyxP0V+es6VPPSUhM6zk8iImp2jbV6GwjX4pap0JFbUQN65X1g==} - engines: {node: '>=18'} - cpu: [arm] - os: [android] - - '@esbuild/android-x64@0.25.0': - resolution: {integrity: sha512-m/ix7SfKG5buCnxasr52+LI78SQ+wgdENi9CqyCXwjVR2X4Jkz+BpC3le3AoBPYTC9NHklwngVXvbJ9/Akhrfg==} - engines: {node: '>=18'} - cpu: [x64] - os: [android] - - '@esbuild/darwin-arm64@0.25.0': - resolution: {integrity: sha512-mVwdUb5SRkPayVadIOI78K7aAnPamoeFR2bT5nszFUZ9P8UpK4ratOdYbZZXYSqPKMHfS1wdHCJk1P1EZpRdvw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [darwin] - - '@esbuild/darwin-x64@0.25.0': - resolution: {integrity: sha512-DgDaYsPWFTS4S3nWpFcMn/33ZZwAAeAFKNHNa1QN0rI4pUjgqf0f7ONmXf6d22tqTY+H9FNdgeaAa+YIFUn2Rg==} - engines: {node: '>=18'} - cpu: [x64] - os: [darwin] - - '@esbuild/freebsd-arm64@0.25.0': - resolution: {integrity: sha512-VN4ocxy6dxefN1MepBx/iD1dH5K8qNtNe227I0mnTRjry8tj5MRk4zprLEdG8WPyAPb93/e4pSgi1SoHdgOa4w==} - engines: {node: '>=18'} - cpu: [arm64] - os: [freebsd] - - '@esbuild/freebsd-x64@0.25.0': - resolution: {integrity: sha512-mrSgt7lCh07FY+hDD1TxiTyIHyttn6vnjesnPoVDNmDfOmggTLXRv8Id5fNZey1gl/V2dyVK1VXXqVsQIiAk+A==} - engines: {node: '>=18'} - cpu: [x64] - os: [freebsd] - - '@esbuild/linux-arm64@0.25.0': - resolution: {integrity: sha512-9QAQjTWNDM/Vk2bgBl17yWuZxZNQIF0OUUuPZRKoDtqF2k4EtYbpyiG5/Dk7nqeK6kIJWPYldkOcBqjXjrUlmg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [linux] - - '@esbuild/linux-arm@0.25.0': - resolution: {integrity: sha512-vkB3IYj2IDo3g9xX7HqhPYxVkNQe8qTK55fraQyTzTX/fxaDtXiEnavv9geOsonh2Fd2RMB+i5cbhu2zMNWJwg==} - engines: {node: '>=18'} - cpu: [arm] - os: [linux] - - '@esbuild/linux-ia32@0.25.0': - resolution: {integrity: sha512-43ET5bHbphBegyeqLb7I1eYn2P/JYGNmzzdidq/w0T8E2SsYL1U6un2NFROFRg1JZLTzdCoRomg8Rvf9M6W6Gg==} - engines: {node: '>=18'} - cpu: [ia32] - os: [linux] - - '@esbuild/linux-loong64@0.25.0': - resolution: {integrity: sha512-fC95c/xyNFueMhClxJmeRIj2yrSMdDfmqJnyOY4ZqsALkDrrKJfIg5NTMSzVBr5YW1jf+l7/cndBfP3MSDpoHw==} - engines: {node: '>=18'} - cpu: [loong64] - os: [linux] - - '@esbuild/linux-mips64el@0.25.0': - resolution: {integrity: sha512-nkAMFju7KDW73T1DdH7glcyIptm95a7Le8irTQNO/qtkoyypZAnjchQgooFUDQhNAy4iu08N79W4T4pMBwhPwQ==} - engines: {node: '>=18'} - cpu: [mips64el] - os: [linux] - - '@esbuild/linux-ppc64@0.25.0': - resolution: {integrity: sha512-NhyOejdhRGS8Iwv+KKR2zTq2PpysF9XqY+Zk77vQHqNbo/PwZCzB5/h7VGuREZm1fixhs4Q/qWRSi5zmAiO4Fw==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [linux] - - '@esbuild/linux-riscv64@0.25.0': - resolution: {integrity: sha512-5S/rbP5OY+GHLC5qXp1y/Mx//e92L1YDqkiBbO9TQOvuFXM+iDqUNG5XopAnXoRH3FjIUDkeGcY1cgNvnXp/kA==} - engines: {node: '>=18'} - cpu: [riscv64] - os: [linux] - - '@esbuild/linux-s390x@0.25.0': - resolution: {integrity: sha512-XM2BFsEBz0Fw37V0zU4CXfcfuACMrppsMFKdYY2WuTS3yi8O1nFOhil/xhKTmE1nPmVyvQJjJivgDT+xh8pXJA==} - engines: {node: '>=18'} - cpu: [s390x] - os: [linux] - - '@esbuild/linux-x64@0.25.0': - resolution: {integrity: sha512-9yl91rHw/cpwMCNytUDxwj2XjFpxML0y9HAOH9pNVQDpQrBxHy01Dx+vaMu0N1CKa/RzBD2hB4u//nfc+Sd3Cw==} - engines: {node: '>=18'} - cpu: [x64] - os: [linux] - - '@esbuild/netbsd-arm64@0.25.0': - resolution: {integrity: sha512-RuG4PSMPFfrkH6UwCAqBzauBWTygTvb1nxWasEJooGSJ/NwRw7b2HOwyRTQIU97Hq37l3npXoZGYMy3b3xYvPw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [netbsd] - - '@esbuild/netbsd-x64@0.25.0': - resolution: {integrity: sha512-jl+qisSB5jk01N5f7sPCsBENCOlPiS/xptD5yxOx2oqQfyourJwIKLRA2yqWdifj3owQZCL2sn6o08dBzZGQzA==} - engines: {node: '>=18'} - cpu: [x64] - os: [netbsd] - - '@esbuild/openbsd-arm64@0.25.0': - resolution: {integrity: sha512-21sUNbq2r84YE+SJDfaQRvdgznTD8Xc0oc3p3iW/a1EVWeNj/SdUCbm5U0itZPQYRuRTW20fPMWMpcrciH2EJw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openbsd] - - '@esbuild/openbsd-x64@0.25.0': - resolution: {integrity: sha512-2gwwriSMPcCFRlPlKx3zLQhfN/2WjJ2NSlg5TKLQOJdV0mSxIcYNTMhk3H3ulL/cak+Xj0lY1Ym9ysDV1igceg==} - engines: {node: '>=18'} - cpu: [x64] - os: [openbsd] - - '@esbuild/sunos-x64@0.25.0': - resolution: {integrity: sha512-bxI7ThgLzPrPz484/S9jLlvUAHYMzy6I0XiU1ZMeAEOBcS0VePBFxh1JjTQt3Xiat5b6Oh4x7UC7IwKQKIJRIg==} - engines: {node: '>=18'} - cpu: [x64] - os: [sunos] - - '@esbuild/win32-arm64@0.25.0': - resolution: {integrity: sha512-ZUAc2YK6JW89xTbXvftxdnYy3m4iHIkDtK3CLce8wg8M2L+YZhIvO1DKpxrd0Yr59AeNNkTiic9YLf6FTtXWMw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [win32] - - '@esbuild/win32-ia32@0.25.0': - resolution: {integrity: sha512-eSNxISBu8XweVEWG31/JzjkIGbGIJN/TrRoiSVZwZ6pkC6VX4Im/WV2cz559/TXLcYbcrDN8JtKgd9DJVIo8GA==} - engines: {node: '>=18'} - cpu: [ia32] - os: [win32] - - '@esbuild/win32-x64@0.25.0': - resolution: {integrity: sha512-ZENoHJBxA20C2zFzh6AI4fT6RraMzjYw4xKWemRTRmRVtN9c5DcH9r/f2ihEkMjOW5eGgrwCslG/+Y/3bL+DHQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [win32] - - '@eslint-community/eslint-utils@4.9.1': - resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - peerDependencies: - eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 - - '@eslint-community/regexpp@4.12.2': - resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} - engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} - - '@eslint/config-array@0.19.2': - resolution: {integrity: sha512-GNKqxfHG2ySmJOBSHg7LxeUx4xpuCoFjacmlCoYWEbaPXLwvfIjixRI12xCQZeULksQb23uiA8F40w5TojpV7w==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@eslint/core@0.10.0': - resolution: {integrity: sha512-gFHJ+xBOo4G3WRlR1e/3G8A6/KZAH6zcE/hkLRCZTi/B9avAG365QhFA8uOGzTMqgTghpn7/fSnscW++dpMSAw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@eslint/core@0.13.0': - resolution: {integrity: sha512-yfkgDw1KR66rkT5A8ci4irzDysN7FRpq3ttJolR88OqQikAWqwA8j5VZyas+vjyBNFIJ7MfybJ9plMILI2UrCw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@eslint/eslintrc@3.3.5': - resolution: {integrity: sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@eslint/js@9.19.0': - resolution: {integrity: sha512-rbq9/g38qjfqFLOVPvwjIvFFdNziEC5S65jmjPw5r6A//QH+W91akh9irMwjDN8zKUTak6W9EsAv4m/7Wnw0UQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@eslint/object-schema@2.1.7': - resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@eslint/plugin-kit@0.2.8': - resolution: {integrity: sha512-ZAoA40rNMPwSm+AeHpCq8STiNAwzWLJuP8Xv4CHIc9wv/PSuExjMrmjfYNj682vW0OOiZ1HKxzvjQr9XZIisQA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@humanfs/core@0.19.2': - resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} - engines: {node: '>=18.18.0'} - - '@humanfs/node@0.16.8': - resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} - engines: {node: '>=18.18.0'} - - '@humanfs/types@0.15.0': - resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} - engines: {node: '>=18.18.0'} - - '@humanwhocodes/module-importer@1.0.1': - resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} - engines: {node: '>=12.22'} - - '@humanwhocodes/retry@0.4.3': - resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} - engines: {node: '>=18.18'} - - '@jridgewell/gen-mapping@0.3.13': - resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} - - '@jridgewell/remapping@2.3.5': - resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} - - '@jridgewell/resolve-uri@3.1.2': - resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} - engines: {node: '>=6.0.0'} - - '@jridgewell/source-map@0.3.11': - resolution: {integrity: sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==} - - '@jridgewell/sourcemap-codec@1.5.5': - resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} - - '@jridgewell/trace-mapping@0.3.31': - resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - - '@mediabunny/aac-encoder@1.42.0': - resolution: {integrity: sha512-Wcxn/Ez5oYNnHtNDjSV1kxrGeHm9Xq9SycdyXBLwbyd3VJvsagR9r/XwtHYOp2CGYKo+biN/NYhJuEORSXkkGQ==} - peerDependencies: - mediabunny: ^1.0.0 - - '@mediabunny/flac-encoder@1.42.0': - resolution: {integrity: sha512-yguntZRxJ9ghVVxV1okbehi5jUgmlGbvjknNAgTzkM0m6F+IGZn9hG6ztW4Pvfdmv8YH1m7WbcB8wj87UHVsyg==} - peerDependencies: - mediabunny: ^1.0.0 - - '@mediabunny/mp3-encoder@1.42.0': - resolution: {integrity: sha512-J6TwGa6rbVpXxcOETL6NVTi8UFYkd/W+DcV0CsQEEEjjMnL9Y5SN1h36Vc1CDSGVDSq62E8PxbG8C8eOIDqbFg==} - peerDependencies: - mediabunny: ^1.0.0 - - '@module-federation/error-codes@0.22.0': - resolution: {integrity: sha512-xF9SjnEy7vTdx+xekjPCV5cIHOGCkdn3pIxo9vU7gEZMIw0SvAEdsy6Uh17xaCpm8V0FWvR0SZoK9Ik6jGOaug==} - - '@module-federation/runtime-core@0.22.0': - resolution: {integrity: sha512-GR1TcD6/s7zqItfhC87zAp30PqzvceoeDGYTgF3Vx2TXvsfDrhP6Qw9T4vudDQL3uJRne6t7CzdT29YyVxlgIA==} - - '@module-federation/runtime-tools@0.22.0': - resolution: {integrity: sha512-4ScUJ/aUfEernb+4PbLdhM/c60VHl698Gn1gY21m9vyC1Ucn69fPCA1y2EwcCB7IItseRMoNhdcWQnzt/OPCNA==} - - '@module-federation/runtime@0.22.0': - resolution: {integrity: sha512-38g5iPju2tPC3KHMPxRKmy4k4onNp6ypFPS1eKGsNLUkXgHsPMBFqAjDw96iEcjri91BrahG4XcdyKi97xZzlA==} - - '@module-federation/sdk@0.22.0': - resolution: {integrity: sha512-x4aFNBKn2KVQRuNVC5A7SnrSCSqyfIWmm1DvubjbO9iKFe7ith5niw8dqSFBekYBg2Fwy+eMg4sEFNVvCAdo6g==} - - '@module-federation/webpack-bundler-runtime@0.22.0': - resolution: {integrity: sha512-aM8gCqXu+/4wBmJtVeMeeMN5guw3chf+2i6HajKtQv7SJfxV/f4IyNQJUeUQu9HfiAZHjqtMV5Lvq/Lvh8LdyA==} - - '@napi-rs/wasm-runtime@1.0.7': - resolution: {integrity: sha512-SeDnOO0Tk7Okiq6DbXmmBODgOAb9dp9gjlphokTUxmt8U3liIP1ZsozBahH69j/RJv+Rfs6IwUKHTgQYJ/HBAw==} - - '@nodelib/fs.scandir@2.1.5': - resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} - engines: {node: '>= 8'} - - '@nodelib/fs.stat@2.0.5': - resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} - engines: {node: '>= 8'} - - '@nodelib/fs.walk@1.2.8': - resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} - engines: {node: '>= 8'} - - '@remotion/bundler@4.0.454': - resolution: {integrity: sha512-KoBH8fDehxGiowSUDELM05UGwntnLlOrWKi5bwiSkQGRX5ZZ2dffuYAeVjXEExAaMnpmWMgxNfFsGN31k6j9oA==} - peerDependencies: - react: '>=16.8.0' - react-dom: '>=16.8.0' - - '@remotion/cli@4.0.454': - resolution: {integrity: sha512-SOrpd1XuxM5SDfQ3ctDsl0RTd21Y8ywFtpd8e6viu0LsMaOn8RdTtn4PXOfi92wE5wZXDd85F+EvOI9qWGwSZw==} - hasBin: true - peerDependencies: - react: '>=16.8.0' - react-dom: '>=16.8.0' - - '@remotion/compositor-darwin-arm64@4.0.454': - resolution: {integrity: sha512-TnpOzH/X150N3xieWQu3uNqfqo5O0phXsLo+cm8XsfOMnOi298THEHRr7XLEGlfcTU3zEZyohinoOUeFjE3pOg==} - cpu: [arm64] - os: [darwin] - - '@remotion/compositor-darwin-x64@4.0.454': - resolution: {integrity: sha512-woXQgM9sr/pvnbQtx3DGpuCxONPdUNFO0PJXs4Af3g/twgN0w7KIsMA6u+2Oh8Z8ylP8NPA+JIjG4pfYK0Iriw==} - cpu: [x64] - os: [darwin] - - '@remotion/compositor-linux-arm64-gnu@4.0.454': - resolution: {integrity: sha512-GCyJCIpFuy2neMVMAMipnduoaNDAZbxxRy+9nxVitevcInG26si6QbWDeE2h2rAqk8WdeeVot/4N3f/r1NygBA==} - cpu: [arm64] - os: [linux] - - '@remotion/compositor-linux-arm64-musl@4.0.454': - resolution: {integrity: sha512-ihisuWz7GRH6pic85hd+aEEw4Stghg/Kpvgr46yzpVxAxknw9Ndl6m1gdJRsUAJBXNZwWFi0+7wED61kWcTJbA==} - cpu: [arm64] - os: [linux] - - '@remotion/compositor-linux-x64-gnu@4.0.454': - resolution: {integrity: sha512-RArpK81ZlhiBe3EoiQqvCqF3XqUAiJP4sEPGSRtJrOksbD5lsW19cXKE5MPUVT0niT8SsSF7b/Arqnpv1TcxsQ==} - cpu: [x64] - os: [linux] - - '@remotion/compositor-linux-x64-musl@4.0.454': - resolution: {integrity: sha512-2GQmNqfyw/YIFG42JsyVR7BB6jxVoAzWyK1uJ/xJ9vDQG9G+PkGad6ehTJK+5689YyuXARGPMD4p8kITix/97g==} - cpu: [x64] - os: [linux] - - '@remotion/compositor-win32-x64-msvc@4.0.454': - resolution: {integrity: sha512-qVFum4MkZaQDZlxomWCBBEnk5jmnzFCAY6kChNWMmamzJaGSBmRp7Hn/ZfL9N9Ad9KEySfnET+t1nvk1tmrOAQ==} - cpu: [x64] - os: [win32] - - '@remotion/eslint-config-flat@4.0.454': - resolution: {integrity: sha512-kWapqQWv8KwC3Z2FRja6/+13aIdC9l1nYD3QGjt7wG04NTgrM0PgLOqlHjmETnOT91NuAO0D1543ODEWN4R+Xg==} - peerDependencies: - eslint: '>=9' - - '@remotion/licensing@4.0.454': - resolution: {integrity: sha512-AZdZ48wKsszV4yeHfLesViAVDxGRGTV7MXCU2iYDn5aJwlvx8iSEyVdYmyQtODqOEaH1b/S7hkgS6RCAD/yUNQ==} - - '@remotion/media-parser@4.0.454': - resolution: {integrity: sha512-gN0o+7XNfz3yCIDpN27yHWTYILdT0OdJTgzta1tZmn5Ads/6LzPqjQUnsbVhfpwyPFCmCEQLBkZB5q+m6NIexA==} - - '@remotion/media-utils@4.0.454': - resolution: {integrity: sha512-s1d7546xr95Txpt6J4uxAhV19I4WsTjzB1RQRxXkwqqzDTkFkFiPOBMGVJKjFIdmg8VFhKwAk6dvohbd346gTw==} - peerDependencies: - react: '>=16.8.0' - react-dom: '>=16.8.0' - - '@remotion/player@4.0.454': - resolution: {integrity: sha512-Q4fEWapH21uEErdOfZxagOQcxbf/JGxPwqBOrW/NG9Y5qHgMMe5tBUCnr8rEqOA4txW/S4QHnN9HGWfV2nIvTg==} - peerDependencies: - react: '>=16.8.0' - react-dom: '>=16.8.0' - - '@remotion/renderer@4.0.454': - resolution: {integrity: sha512-Zq9pUEyHi02uoAnvSZ5G0Bu+NLtEq6ZUJMexw+O0sY3ZmG4XqujjcnIx3ktJAXwVv61qvXoP1Mvs4LV9aaoUFw==} - peerDependencies: - react: '>=16.8.0' - react-dom: '>=16.8.0' - - '@remotion/streaming@4.0.454': - resolution: {integrity: sha512-rt5s7NPmXAqo8quP/CPZ5FwRY9pkLxSrcBjykvFm86qHfnQBGSkZW6lo49fp1E+7GN+PoF0wr2B5hk5Ls0kt/g==} - - '@remotion/studio-server@4.0.454': - resolution: {integrity: sha512-JMSt68Re/Dq+2Y6EKy5dfi11bsfGkcJ7H69h094LVNcD30XcaYZWkJLYFh/VPtqra9rKZSEEJ5TZnHUp1tsf3w==} - - '@remotion/studio-shared@4.0.454': - resolution: {integrity: sha512-GSeVzeaS+UY+jrYB83t8aDvQe7zg2sgsWcsVNk2onp+j1gSmpxDpWdaZT3Uxdr4+iM0mgNxTZR4QPBmnuKiwTw==} - - '@remotion/studio@4.0.454': - resolution: {integrity: sha512-uZPbmHS/+w9WbZ0o6sC/Ha2SHwEDfVnjjR8zmu2rJKfv3vtdAmIXkEuYFzJ3KHv9g0KRLV/GpiQLO+00dLb3mg==} - peerDependencies: - react: '>=16.8.0' - react-dom: '>=16.8.0' - - '@remotion/tailwind-v4@4.0.454': - resolution: {integrity: sha512-DK5YCuX0sBjYg4fT9WQO9oRHINbSWJb5+OzMR4M3dLIqDewl/Y5Cmne+dNb70FKFKXe6YS+5URDDSZk/mvFgQA==} - peerDependencies: - '@remotion/bundler': 4.0.454 - - '@remotion/web-renderer@4.0.454': - resolution: {integrity: sha512-OBPG3fL40hxJ+cwlBAoN+0AuRlqe47/wKYxkOu9ZZ2Pug3tR+Ph8WPjqRL7ZuwqYsauvjwkooCdHq5Z41DAk+A==} - peerDependencies: - react: '>=18.0.0' - react-dom: '>=18.0.0' - - '@remotion/zod-types@4.0.454': - resolution: {integrity: sha512-nNXcHF4AmgihwI7sMx50rSgFuCfNyroP0OsgN/mMkQI7Y66uPWBh4Pcytlxs039EShvsiiH621E2TKZOX8ciRA==} - peerDependencies: - zod: 4.3.6 - - '@rspack/binding-darwin-arm64@1.7.6': - resolution: {integrity: sha512-NZ9AWtB1COLUX1tA9HQQvWpTy07NSFfKBU8A6ylWd5KH8AePZztpNgLLAVPTuNO4CZXYpwcoclf8jG/luJcQdQ==} - cpu: [arm64] - os: [darwin] - - '@rspack/binding-darwin-x64@1.7.6': - resolution: {integrity: sha512-J2g6xk8ZS7uc024dNTGTHxoFzFovAZIRixUG7PiciLKTMP78svbSSWrmW6N8oAsAkzYfJWwQpVgWfFNRHvYxSw==} - cpu: [x64] - os: [darwin] - - '@rspack/binding-linux-arm64-gnu@1.7.6': - resolution: {integrity: sha512-eQfcsaxhFrv5FmtaA7+O1F9/2yFDNIoPZzV/ZvqvFz5bBXVc4FAm/1fVpBg8Po/kX1h0chBc7Xkpry3cabFW8w==} - cpu: [arm64] - os: [linux] - - '@rspack/binding-linux-arm64-musl@1.7.6': - resolution: {integrity: sha512-DfQXKiyPIl7i1yECHy4eAkSmlUzzsSAbOjgMuKn7pudsWf483jg0UUYutNgXSlBjc/QSUp7906Cg8oty9OfwPA==} - cpu: [arm64] - os: [linux] - - '@rspack/binding-linux-x64-gnu@1.7.6': - resolution: {integrity: sha512-NdA+2X3lk2GGrMMnTGyYTzM3pn+zNjaqXqlgKmFBXvjfZqzSsKq3pdD1KHZCd5QHN+Fwvoszj0JFsquEVhE1og==} - cpu: [x64] - os: [linux] - - '@rspack/binding-linux-x64-musl@1.7.6': - resolution: {integrity: sha512-rEy6MHKob02t/77YNgr6dREyJ0e0tv1X6Xsg8Z5E7rPXead06zefUbfazj4RELYySWnM38ovZyJAkPx/gOn3VA==} - cpu: [x64] - os: [linux] - - '@rspack/binding-wasm32-wasi@1.7.6': - resolution: {integrity: sha512-YupOrz0daSG+YBbCIgpDgzfMM38YpChv+afZpaxx5Ml7xPeAZIIdgWmLHnQ2rts73N2M1NspAiBwV00Xx0N4Vg==} - cpu: [wasm32] - - '@rspack/binding-win32-arm64-msvc@1.7.6': - resolution: {integrity: sha512-INj7aVXjBvlZ84kEhSK4kJ484ub0i+BzgnjDWOWM1K+eFYDZjLdAsQSS3fGGXwVc3qKbPIssFfnftATDMTEJHQ==} - cpu: [arm64] - os: [win32] - - '@rspack/binding-win32-ia32-msvc@1.7.6': - resolution: {integrity: sha512-lXGvC+z67UMcw58In12h8zCa9IyYRmuptUBMItQJzu+M278aMuD1nETyGLL7e4+OZ2lvrnnBIcjXN1hfw2yRzw==} - cpu: [ia32] - os: [win32] - - '@rspack/binding-win32-x64-msvc@1.7.6': - resolution: {integrity: sha512-zeUxEc0ZaPpmaYlCeWcjSJUPuRRySiSHN23oJ2Xyw0jsQ01Qm4OScPdr0RhEOFuK/UE+ANyRtDo4zJsY52Hadw==} - cpu: [x64] - os: [win32] - - '@rspack/binding@1.7.6': - resolution: {integrity: sha512-/NrEcfo8Gx22hLGysanrV6gHMuqZSxToSci/3M4kzEQtF5cPjfOv5pqeLK/+B6cr56ul/OmE96cCdWcXeVnFjQ==} - - '@rspack/core@1.7.6': - resolution: {integrity: sha512-Iax6UhrfZqJajA778c1d5DBFbSIqPOSrI34kpNIiNpWd8Jq7mFIa+Z60SQb5ZQDZuUxcCZikjz5BxinFjTkg7Q==} - engines: {node: '>=18.12.0'} - peerDependencies: - '@swc/helpers': '>=0.5.1' - peerDependenciesMeta: - '@swc/helpers': - optional: true - - '@rspack/lite-tapable@1.1.0': - resolution: {integrity: sha512-E2B0JhYFmVAwdDiG14+DW0Di4Ze4Jg10Pc4/lILUrd5DRCaklduz2OvJ5HYQ6G+hd+WTzqQb3QnDNfK4yvAFYw==} - - '@rspack/plugin-react-refresh@1.6.1': - resolution: {integrity: sha512-eqqW5645VG3CzGzFgNg5HqNdHVXY+567PGjtDhhrM8t67caxmsSzRmT5qfoEIfBcGgFkH9vEg7kzXwmCYQdQDw==} - peerDependencies: - react-refresh: '>=0.10.0 <1.0.0' - webpack-hot-middleware: 2.x - peerDependenciesMeta: - webpack-hot-middleware: - optional: true - - '@tailwindcss/node@4.2.0': - resolution: {integrity: sha512-Yv+fn/o2OmL5fh/Ir62VXItdShnUxfpkMA4Y7jdeC8O81WPB8Kf6TT6GSHvnqgSwDzlB5iT7kDpeXxLsUS0T6Q==} - - '@tailwindcss/oxide-android-arm64@4.2.0': - resolution: {integrity: sha512-F0QkHAVaW/JNBWl4CEKWdZ9PMb0khw5DCELAOnu+RtjAfx5Zgw+gqCHFvqg3AirU1IAd181fwOtJQ5I8Yx5wtw==} - engines: {node: '>= 20'} - cpu: [arm64] - os: [android] - - '@tailwindcss/oxide-darwin-arm64@4.2.0': - resolution: {integrity: sha512-I0QylkXsBsJMZ4nkUNSR04p6+UptjcwhcVo3Zu828ikiEqHjVmQL9RuQ6uT/cVIiKpvtVA25msu/eRV97JeNSA==} - engines: {node: '>= 20'} - cpu: [arm64] - os: [darwin] - - '@tailwindcss/oxide-darwin-x64@4.2.0': - resolution: {integrity: sha512-6TmQIn4p09PBrmnkvbYQ0wbZhLtbaksCDx7Y7R3FYYx0yxNA7xg5KP7dowmQ3d2JVdabIHvs3Hx4K3d5uCf8xg==} - engines: {node: '>= 20'} - cpu: [x64] - os: [darwin] - - '@tailwindcss/oxide-freebsd-x64@4.2.0': - resolution: {integrity: sha512-qBudxDvAa2QwGlq9y7VIzhTvp2mLJ6nD/G8/tI70DCDoneaUeLWBJaPcbfzqRIWraj+o969aDQKvKW9dvkUizw==} - engines: {node: '>= 20'} - cpu: [x64] - os: [freebsd] - - '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.0': - resolution: {integrity: sha512-7XKkitpy5NIjFZNUQPeUyNJNJn1CJeV7rmMR+exHfTuOsg8rxIO9eNV5TSEnqRcaOK77zQpsyUkBWmPy8FgdSg==} - engines: {node: '>= 20'} - cpu: [arm] - os: [linux] - - '@tailwindcss/oxide-linux-arm64-gnu@4.2.0': - resolution: {integrity: sha512-Mff5a5Q3WoQR01pGU1gr29hHM1N93xYrKkGXfPw/aRtK4bOc331Ho4Tgfsm5WDGvpevqMpdlkCojT3qlCQbCpA==} - engines: {node: '>= 20'} - cpu: [arm64] - os: [linux] - - '@tailwindcss/oxide-linux-arm64-musl@4.2.0': - resolution: {integrity: sha512-XKcSStleEVnbH6W/9DHzZv1YhjE4eSS6zOu2eRtYAIh7aV4o3vIBs+t/B15xlqoxt6ef/0uiqJVB6hkHjWD/0A==} - engines: {node: '>= 20'} - cpu: [arm64] - os: [linux] - - '@tailwindcss/oxide-linux-x64-gnu@4.2.0': - resolution: {integrity: sha512-/hlXCBqn9K6fi7eAM0RsobHwJYa5V/xzWspVTzxnX+Ft9v6n+30Pz8+RxCn7sQL/vRHHLS30iQPrHQunu6/vJA==} - engines: {node: '>= 20'} - cpu: [x64] - os: [linux] - - '@tailwindcss/oxide-linux-x64-musl@4.2.0': - resolution: {integrity: sha512-lKUaygq4G7sWkhQbfdRRBkaq4LY39IriqBQ+Gk6l5nKq6Ay2M2ZZb1tlIyRNgZKS8cbErTwuYSor0IIULC0SHw==} - engines: {node: '>= 20'} - cpu: [x64] - os: [linux] - - '@tailwindcss/oxide-wasm32-wasi@4.2.0': - resolution: {integrity: sha512-xuDjhAsFdUuFP5W9Ze4k/o4AskUtI8bcAGU4puTYprr89QaYFmhYOPfP+d1pH+k9ets6RoE23BXZM1X1jJqoyw==} - engines: {node: '>=14.0.0'} - cpu: [wasm32] - bundledDependencies: - - '@napi-rs/wasm-runtime' - - '@emnapi/core' - - '@emnapi/runtime' - - '@tybys/wasm-util' - - '@emnapi/wasi-threads' - - tslib - - '@tailwindcss/oxide-win32-arm64-msvc@4.2.0': - resolution: {integrity: sha512-2UU/15y1sWDEDNJXxEIrfWKC2Yb4YgIW5Xz2fKFqGzFWfoMHWFlfa1EJlGO2Xzjkq/tvSarh9ZTjvbxqWvLLXA==} - engines: {node: '>= 20'} - cpu: [arm64] - os: [win32] - - '@tailwindcss/oxide-win32-x64-msvc@4.2.0': - resolution: {integrity: sha512-CrFadmFoc+z76EV6LPG1jx6XceDsaCG3lFhyLNo/bV9ByPrE+FnBPckXQVP4XRkN76h3Fjt/a+5Er/oA/nCBvQ==} - engines: {node: '>= 20'} - cpu: [x64] - os: [win32] - - '@tailwindcss/oxide@4.2.0': - resolution: {integrity: sha512-AZqQzADaj742oqn2xjl5JbIOzZB/DGCYF/7bpvhA8KvjUj9HJkag6bBuwZvH1ps6dfgxNHyuJVlzSr2VpMgdTQ==} - engines: {node: '>= 20'} - - '@tailwindcss/webpack@4.2.0': - resolution: {integrity: sha512-TohlILOzF/3l+Yolll9iD2b5bsLG/1TuxAXPTxJcA77YAgPyKKc5RIwj4vIjg4l9DFMidekx2Z0BWyPAle9TUA==} - peerDependencies: - webpack: ^5 - - '@tybys/wasm-util@0.10.2': - resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} - - '@types/dom-mediacapture-transform@0.1.11': - resolution: {integrity: sha512-Y2p+nGf1bF2XMttBnsVPHUWzRRZzqUoJAKmiP10b5umnO6DDrWI0BrGDJy1pOHoOULVmGSfFNkQrAlC5dcj6nQ==} - - '@types/dom-webcodecs@0.1.13': - resolution: {integrity: sha512-O5hkiFIcjjszPIYyUSyvScyvrBoV3NOEEZx/pMlsu44TKzWNkLVBBxnxJz42in5n3QIolYOcBYFCPZZ0h8SkwQ==} - - '@types/eslint-scope@3.7.7': - resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==} - - '@types/eslint@9.6.1': - resolution: {integrity: sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==} - - '@types/estree@1.0.8': - resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} - - '@types/json-schema@7.0.15': - resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} - - '@types/node@25.6.0': - resolution: {integrity: sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==} - - '@types/react@19.2.7': - resolution: {integrity: sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==} - - '@types/web@0.0.166': - resolution: {integrity: sha512-qvY/TzK1WuxfeACL3Zzw+gMivGiIynRKH98nLET7ACzTRTX8CWMA6LQJ9WayIHvTBU1JeFCBRIBjsxhGz4TfHQ==} - - '@types/yauzl@2.10.3': - resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==} - - '@typescript-eslint/eslint-plugin@8.21.0': - resolution: {integrity: sha512-eTH+UOR4I7WbdQnG4Z48ebIA6Bgi7WO8HvFEneeYBxG8qCOYgTOFPSg6ek9ITIDvGjDQzWHcoWHCDO2biByNzA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - '@typescript-eslint/parser': ^8.0.0 || ^8.0.0-alpha.0 - eslint: ^8.57.0 || ^9.0.0 - typescript: '>=4.8.4 <5.8.0' - - '@typescript-eslint/parser@8.21.0': - resolution: {integrity: sha512-Wy+/sdEH9kI3w9civgACwabHbKl+qIOu0uFZ9IMKzX3Jpv9og0ZBJrZExGrPpFAY7rWsXuxs5e7CPPP17A4eYA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 - typescript: '>=4.8.4 <5.8.0' - - '@typescript-eslint/scope-manager@8.21.0': - resolution: {integrity: sha512-G3IBKz0/0IPfdeGRMbp+4rbjfSSdnGkXsM/pFZA8zM9t9klXDnB/YnKOBQ0GoPmoROa4bCq2NeHgJa5ydsQ4mA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@typescript-eslint/type-utils@8.21.0': - resolution: {integrity: sha512-95OsL6J2BtzoBxHicoXHxgk3z+9P3BEcQTpBKriqiYzLKnM2DeSqs+sndMKdamU8FosiadQFT3D+BSL9EKnAJQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 - typescript: '>=4.8.4 <5.8.0' - - '@typescript-eslint/types@8.21.0': - resolution: {integrity: sha512-PAL6LUuQwotLW2a8VsySDBwYMm129vFm4tMVlylzdoTybTHaAi0oBp7Ac6LhSrHHOdLM3efH+nAR6hAWoMF89A==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@typescript-eslint/typescript-estree@8.21.0': - resolution: {integrity: sha512-x+aeKh/AjAArSauz0GiQZsjT8ciadNMHdkUSwBB9Z6PrKc/4knM4g3UfHml6oDJmKC88a6//cdxnO/+P2LkMcg==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - typescript: '>=4.8.4 <5.8.0' - - '@typescript-eslint/utils@8.21.0': - resolution: {integrity: sha512-xcXBfcq0Kaxgj7dwejMbFyq7IOHgpNMtVuDveK7w3ZGwG9owKzhALVwKpTF2yrZmEwl9SWdetf3fxNzJQaVuxw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 - typescript: '>=4.8.4 <5.8.0' - - '@typescript-eslint/visitor-keys@8.21.0': - resolution: {integrity: sha512-BkLMNpdV6prozk8LlyK/SOoWLmUFi+ZD+pcqti9ILCbVvHGk1ui1g4jJOc2WDLaeExz2qWwojxlPce5PljcT3w==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@webassemblyjs/ast@1.14.1': - resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==} - - '@webassemblyjs/floating-point-hex-parser@1.13.2': - resolution: {integrity: sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==} - - '@webassemblyjs/helper-api-error@1.13.2': - resolution: {integrity: sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==} - - '@webassemblyjs/helper-buffer@1.14.1': - resolution: {integrity: sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==} - - '@webassemblyjs/helper-numbers@1.13.2': - resolution: {integrity: sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==} - - '@webassemblyjs/helper-wasm-bytecode@1.13.2': - resolution: {integrity: sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==} - - '@webassemblyjs/helper-wasm-section@1.14.1': - resolution: {integrity: sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==} - - '@webassemblyjs/ieee754@1.13.2': - resolution: {integrity: sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==} - - '@webassemblyjs/leb128@1.13.2': - resolution: {integrity: sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==} - - '@webassemblyjs/utf8@1.13.2': - resolution: {integrity: sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==} - - '@webassemblyjs/wasm-edit@1.14.1': - resolution: {integrity: sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==} - - '@webassemblyjs/wasm-gen@1.14.1': - resolution: {integrity: sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==} - - '@webassemblyjs/wasm-opt@1.14.1': - resolution: {integrity: sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==} - - '@webassemblyjs/wasm-parser@1.14.1': - resolution: {integrity: sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==} - - '@webassemblyjs/wast-printer@1.14.1': - resolution: {integrity: sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==} - - '@xtuc/ieee754@1.2.0': - resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==} - - '@xtuc/long@4.2.2': - resolution: {integrity: sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==} - - acorn-import-phases@1.0.4: - resolution: {integrity: sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==} - engines: {node: '>=10.13.0'} - peerDependencies: - acorn: ^8.14.0 - - acorn-jsx@5.3.2: - resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} - peerDependencies: - acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 - - acorn@8.16.0: - resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} - engines: {node: '>=0.4.0'} - hasBin: true - - ajv-formats@2.1.1: - resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==} - peerDependencies: - ajv: ^8.0.0 - peerDependenciesMeta: - ajv: - optional: true - - ajv-keywords@5.1.0: - resolution: {integrity: sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==} - peerDependencies: - ajv: ^8.8.2 - - ajv@6.15.0: - resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} - - ajv@8.20.0: - resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} - - ansi-styles@4.3.0: - resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} - engines: {node: '>=8'} - - argparse@2.0.1: - resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} - - ast-types@0.16.1: - resolution: {integrity: sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==} - engines: {node: '>=4'} - - balanced-match@1.0.2: - resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} - - baseline-browser-mapping@2.10.27: - resolution: {integrity: sha512-zEs/ufmZoUd7WftKpKyXaT6RFxpQ5Qm9xytKRHvJfxFV9DFJkZph9RvJ1LcOUi0Z1ZVijMte65JbILeV+8QQEA==} - engines: {node: '>=6.0.0'} - hasBin: true - - big.js@5.2.2: - resolution: {integrity: sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==} - - brace-expansion@1.1.14: - resolution: {integrity: sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==} - - brace-expansion@2.1.0: - resolution: {integrity: sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==} - - braces@3.0.3: - resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} - engines: {node: '>=8'} - - browserslist@4.28.2: - resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} - engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} - hasBin: true - - buffer-crc32@0.2.13: - resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} - - buffer-from@1.1.2: - resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} - - callsites@3.1.0: - resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} - engines: {node: '>=6'} - - caniuse-lite@1.0.30001791: - resolution: {integrity: sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ==} - - chalk@4.1.2: - resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} - engines: {node: '>=10'} - - chrome-trace-event@1.0.4: - resolution: {integrity: sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==} - engines: {node: '>=6.0'} - - color-convert@2.0.1: - resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} - engines: {node: '>=7.0.0'} - - color-name@1.1.4: - resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} - - commander@2.20.3: - resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} - - concat-map@0.0.1: - resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} - - cross-spawn@7.0.6: - resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} - engines: {node: '>= 8'} - - csstype@3.2.3: - resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} - - debug@4.4.3: - resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} - engines: {node: '>=6.0'} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - - deep-is@0.1.4: - resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} - - define-lazy-prop@2.0.0: - resolution: {integrity: sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==} - engines: {node: '>=8'} - - detect-libc@2.1.2: - resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} - engines: {node: '>=8'} - - dotenv@17.3.1: - resolution: {integrity: sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==} - engines: {node: '>=12'} - - electron-to-chromium@1.5.349: - resolution: {integrity: sha512-QsWVGyRuY07Aqb234QytTfwd5d9AJlfNIQ5wIOl1L+PZDzI9d9+Fn0FRale/QYlFxt/bUnB0/nLd1jFPGxGK1A==} - - emojis-list@3.0.0: - resolution: {integrity: sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==} - engines: {node: '>= 4'} - - end-of-stream@1.4.5: - resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} - - enhanced-resolve@5.21.0: - resolution: {integrity: sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA==} - engines: {node: '>=10.13.0'} - - error-stack-parser@2.1.4: - resolution: {integrity: sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ==} - - es-module-lexer@2.1.0: - resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} - - esbuild@0.25.0: - resolution: {integrity: sha512-BXq5mqc8ltbaN34cDqWuYKyNhX8D/Z0J1xdtdQ8UcIIIyJyz+ZMKUt58tF3SrZ85jcfN/PZYhjR5uDQAYNVbuw==} - engines: {node: '>=18'} - hasBin: true - - escalade@3.2.0: - resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} - engines: {node: '>=6'} - - escape-string-regexp@4.0.0: - resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} - engines: {node: '>=10'} - - eslint-scope@5.1.1: - resolution: {integrity: sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==} - engines: {node: '>=8.0.0'} - - eslint-scope@8.4.0: - resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - eslint-visitor-keys@3.4.3: - resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - - eslint-visitor-keys@4.2.1: - resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - eslint@9.19.0: - resolution: {integrity: sha512-ug92j0LepKlbbEv6hD911THhoRHmbdXt2gX+VDABAW/Ir7D3nqKdv5Pf5vtlyY6HQMTEP2skXY43ueqTCWssEA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - hasBin: true - peerDependencies: - jiti: '*' - peerDependenciesMeta: - jiti: - optional: true - - espree@10.4.0: - resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - esprima@4.0.1: - resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} - engines: {node: '>=4'} - hasBin: true - - esquery@1.7.0: - resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} - engines: {node: '>=0.10'} - - esrecurse@4.3.0: - resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} - engines: {node: '>=4.0'} - - estraverse@4.3.0: - resolution: {integrity: sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==} - engines: {node: '>=4.0'} - - estraverse@5.3.0: - resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} - engines: {node: '>=4.0'} - - esutils@2.0.3: - resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} - engines: {node: '>=0.10.0'} - - events@3.3.0: - resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} - engines: {node: '>=0.8.x'} - - execa@5.1.1: - resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} - engines: {node: '>=10'} - - extract-zip@2.0.1: - resolution: {integrity: sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==} - engines: {node: '>= 10.17.0'} - hasBin: true - - fast-deep-equal@3.1.3: - resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} - - fast-glob@3.3.3: - resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} - engines: {node: '>=8.6.0'} - - fast-json-stable-stringify@2.1.0: - resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} - - fast-levenshtein@2.0.6: - resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} - - fast-uri@3.1.1: - resolution: {integrity: sha512-h2r7rcm6Ee/J8o0LD5djLuFVcfbZxhvho4vvsbeV0aMvXjUgqv4YpxpkEx0d68l6+IleVfLAdVEfhR7QNMkGHQ==} - - fastq@1.20.1: - resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} - - fd-slicer@1.1.0: - resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==} - - file-entry-cache@8.0.0: - resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} - engines: {node: '>=16.0.0'} - - fill-range@7.1.1: - resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} - engines: {node: '>=8'} - - find-up@5.0.0: - resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} - engines: {node: '>=10'} - - flat-cache@4.0.1: - resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} - engines: {node: '>=16'} - - flatted@3.4.2: - resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} - - fs-monkey@1.0.3: - resolution: {integrity: sha512-cybjIfiiE+pTWicSCLFHSrXZ6EilF30oh91FDP9S2B051prEa7QWfrVTQm10/dDpswBDXZugPa1Ogu8Yh+HV0Q==} - - get-stream@5.2.0: - resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==} - engines: {node: '>=8'} - - get-stream@6.0.1: - resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} - engines: {node: '>=10'} - - glob-parent@5.1.2: - resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} - engines: {node: '>= 6'} - - glob-parent@6.0.2: - resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} - engines: {node: '>=10.13.0'} - - glob-to-regexp@0.4.1: - resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==} - - globals@14.0.0: - resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} - engines: {node: '>=18'} - - graceful-fs@4.2.11: - resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - - graphemer@1.4.0: - resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} - - has-flag@4.0.0: - resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} - engines: {node: '>=8'} - - html-entities@2.6.0: - resolution: {integrity: sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==} - - human-signals@2.1.0: - resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} - engines: {node: '>=10.17.0'} - - ignore@5.3.2: - resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} - engines: {node: '>= 4'} - - import-fresh@3.3.1: - resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} - engines: {node: '>=6'} - - imurmurhash@0.1.4: - resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} - engines: {node: '>=0.8.19'} - - is-docker@2.2.1: - resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==} - engines: {node: '>=8'} - hasBin: true - - is-extglob@2.1.1: - resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} - engines: {node: '>=0.10.0'} - - is-glob@4.0.3: - resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} - engines: {node: '>=0.10.0'} - - is-number@7.0.0: - resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} - engines: {node: '>=0.12.0'} - - is-stream@2.0.1: - resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} - engines: {node: '>=8'} - - is-wsl@2.2.0: - resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==} - engines: {node: '>=8'} - - isexe@2.0.0: - resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - - jest-worker@27.5.1: - resolution: {integrity: sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==} - engines: {node: '>= 10.13.0'} - - jiti@2.6.1: - resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} - hasBin: true - - js-yaml@4.1.1: - resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} - hasBin: true - - json-buffer@3.0.1: - resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} - - json-parse-even-better-errors@2.3.1: - resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} - - json-schema-traverse@0.4.1: - resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} - - json-schema-traverse@1.0.0: - resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} - - json-stable-stringify-without-jsonify@1.0.1: - resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} - - json5@2.2.3: - resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} - engines: {node: '>=6'} - hasBin: true - - keyv@4.5.4: - resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} - - kleur@3.0.3: - resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} - engines: {node: '>=6'} - - levn@0.4.1: - resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} - engines: {node: '>= 0.8.0'} - - lightningcss-android-arm64@1.31.1: - resolution: {integrity: sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [android] - - lightningcss-darwin-arm64@1.31.1: - resolution: {integrity: sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [darwin] - - lightningcss-darwin-x64@1.31.1: - resolution: {integrity: sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [darwin] - - lightningcss-freebsd-x64@1.31.1: - resolution: {integrity: sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [freebsd] - - lightningcss-linux-arm-gnueabihf@1.31.1: - resolution: {integrity: sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==} - engines: {node: '>= 12.0.0'} - cpu: [arm] - os: [linux] - - lightningcss-linux-arm64-gnu@1.31.1: - resolution: {integrity: sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [linux] - - lightningcss-linux-arm64-musl@1.31.1: - resolution: {integrity: sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [linux] - - lightningcss-linux-x64-gnu@1.31.1: - resolution: {integrity: sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [linux] - - lightningcss-linux-x64-musl@1.31.1: - resolution: {integrity: sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [linux] - - lightningcss-win32-arm64-msvc@1.31.1: - resolution: {integrity: sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [win32] - - lightningcss-win32-x64-msvc@1.31.1: - resolution: {integrity: sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [win32] - - lightningcss@1.31.1: - resolution: {integrity: sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==} - engines: {node: '>= 12.0.0'} - - loader-runner@4.3.2: - resolution: {integrity: sha512-DFEqQ3ihfS9blba08cLfYf1NRAIEm+dDjic073DRDc3/JspI/8wYmtDsHwd3+4hwvdxSK7PGaElfTmm0awWJ4w==} - engines: {node: '>=6.11.5'} - - loader-utils@2.0.4: - resolution: {integrity: sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw==} - engines: {node: '>=8.9.0'} - - locate-path@6.0.0: - resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} - engines: {node: '>=10'} - - lodash.merge@4.6.2: - resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} - - lodash.sortby@4.7.0: - resolution: {integrity: sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==} - - lru-cache@6.0.0: - resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} - engines: {node: '>=10'} - - magic-string@0.30.21: - resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} - - mediabunny@1.42.0: - resolution: {integrity: sha512-s9ypTqLi6kbh95gC+YaJlG0PkLvMxu37Q/wO/pFZx0fUCA5Ym5mp+2dWoa83mKQ3Uo18aNlgev5iJ5ESZqWwgQ==} - - memfs@3.4.3: - resolution: {integrity: sha512-eivjfi7Ahr6eQTn44nvTnR60e4a1Fs1Via2kCR5lHo/kyNoiMWaXCNJ/GpSd0ilXas2JSOl9B5FTIhflXu0hlg==} - engines: {node: '>= 4.0.0'} - - merge-stream@2.0.0: - resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} - - merge2@1.4.1: - resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} - engines: {node: '>= 8'} - - micromatch@4.0.8: - resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} - engines: {node: '>=8.6'} - - mime-db@1.52.0: - resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} - engines: {node: '>= 0.6'} - - mime-types@2.1.35: - resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} - engines: {node: '>= 0.6'} - - mimic-fn@2.1.0: - resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} - engines: {node: '>=6'} - - minimatch@3.1.5: - resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} - - minimatch@9.0.9: - resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} - engines: {node: '>=16 || 14 >=14.17'} - - minimist@1.2.6: - resolution: {integrity: sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==} - - ms@2.1.3: - resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - - nanoid@3.3.12: - resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} - engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} - hasBin: true - - natural-compare@1.4.0: - resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} - - neo-async@2.6.2: - resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} - - node-releases@2.0.38: - resolution: {integrity: sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==} - - npm-run-path@4.0.1: - resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} - engines: {node: '>=8'} - - once@1.4.0: - resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} - - onetime@5.1.2: - resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} - engines: {node: '>=6'} - - open@8.4.2: - resolution: {integrity: sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==} - engines: {node: '>=12'} - - optionator@0.9.4: - resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} - engines: {node: '>= 0.8.0'} - - p-limit@3.1.0: - resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} - engines: {node: '>=10'} - - p-locate@5.0.0: - resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} - engines: {node: '>=10'} - - parent-module@1.0.1: - resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} - engines: {node: '>=6'} - - path-exists@4.0.0: - resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} - engines: {node: '>=8'} - - path-key@3.1.1: - resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} - engines: {node: '>=8'} - - pend@1.2.0: - resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} - - picocolors@1.1.1: - resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - - picomatch@2.3.2: - resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} - engines: {node: '>=8.6'} - - postcss-value-parser@4.2.0: - resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} - - postcss@8.5.10: - resolution: {integrity: sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==} - engines: {node: ^10 || ^12 || >=14} - - prelude-ls@1.2.1: - resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} - engines: {node: '>= 0.8.0'} - - prettier@3.8.1: - resolution: {integrity: sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==} - engines: {node: '>=14'} - hasBin: true - - prompts@2.4.2: - resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} - engines: {node: '>= 6'} - - pump@3.0.4: - resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} - - punycode@2.3.1: - resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} - engines: {node: '>=6'} - - queue-microtask@1.2.3: - resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} - - react-dom@19.2.3: - resolution: {integrity: sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==} - peerDependencies: - react: ^19.2.3 - - react-refresh@0.18.0: - resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==} - engines: {node: '>=0.10.0'} - - react@19.2.3: - resolution: {integrity: sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==} - engines: {node: '>=0.10.0'} - - recast@0.23.11: - resolution: {integrity: sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==} - engines: {node: '>= 4'} - - remotion@4.0.454: - resolution: {integrity: sha512-NGzA7HLBpzRPo1jAyLGXG7AaAdUYHJ18n06GM3nBvFkC5zVZua+2rhezxDTkMJFEMgL54cRQ2Zw/2yd3cWCRbg==} - peerDependencies: - react: '>=16.8.0' - react-dom: '>=16.8.0' - - require-from-string@2.0.2: - resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} - engines: {node: '>=0.10.0'} - - resolve-from@4.0.0: - resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} - engines: {node: '>=4'} - - reusify@1.1.0: - resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} - engines: {iojs: '>=1.0.0', node: '>=0.10.0'} - - run-parallel@1.2.0: - resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} - - scheduler@0.27.0: - resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} - - schema-utils@4.3.3: - resolution: {integrity: sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==} - engines: {node: '>= 10.13.0'} - - semver@7.5.3: - resolution: {integrity: sha512-QBlUtyVk/5EeHbi7X0fw6liDZc7BBmEaSYn01fMU1OUYbf6GPsbTtd8WmnqbI20SeycoHSeiybkE/q1Q+qlThQ==} - engines: {node: '>=10'} - hasBin: true - - semver@7.7.4: - resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} - engines: {node: '>=10'} - hasBin: true - - shebang-command@2.0.0: - resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} - engines: {node: '>=8'} - - shebang-regex@3.0.0: - resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} - engines: {node: '>=8'} - - signal-exit@3.0.7: - resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} - - sisteransi@1.0.5: - resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} - - source-map-js@1.2.1: - resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} - engines: {node: '>=0.10.0'} - - source-map-support@0.5.21: - resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} - - source-map@0.6.1: - resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} - engines: {node: '>=0.10.0'} - - source-map@0.7.3: - resolution: {integrity: sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ==} - engines: {node: '>= 8'} - - source-map@0.8.0-beta.0: - resolution: {integrity: sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA==} - engines: {node: '>= 8'} - deprecated: The work that was done in this beta branch won't be included in future versions - - stackframe@1.3.4: - resolution: {integrity: sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==} - - strip-final-newline@2.0.0: - resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} - engines: {node: '>=6'} - - strip-json-comments@3.1.1: - resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} - engines: {node: '>=8'} - - style-loader@4.0.0: - resolution: {integrity: sha512-1V4WqhhZZgjVAVJyt7TdDPZoPBPNHbekX4fWnCJL1yQukhCeZhJySUL+gL9y6sNdN95uEOS83Y55SqHcP7MzLA==} - engines: {node: '>= 18.12.0'} - peerDependencies: - webpack: ^5.27.0 - - supports-color@7.2.0: - resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} - engines: {node: '>=8'} - - supports-color@8.1.1: - resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} - engines: {node: '>=10'} - - tailwindcss@4.0.0: - resolution: {integrity: sha512-ULRPI3A+e39T7pSaf1xoi58AqqJxVCLg8F/uM5A3FadUbnyDTgltVnXJvdkTjwCOGA6NazqHVcwPJC5h2vRYVQ==} - - tailwindcss@4.2.0: - resolution: {integrity: sha512-yYzTZ4++b7fNYxFfpnberEEKu43w44aqDMNM9MHMmcKuCH7lL8jJ4yJ7LGHv7rSwiqM0nkiobF9I6cLlpS2P7Q==} - - tapable@2.3.3: - resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} - engines: {node: '>=6'} - - terser-webpack-plugin@5.5.0: - resolution: {integrity: sha512-UYhptBwhWvfIjKd/UuFo6D8uq9xpGLDK+z8EDsj/zWhrTaH34cKEbrkMKfV5YWqGBvAYA3tlzZbs2R+qYrbQJA==} - engines: {node: '>= 10.13.0'} - peerDependencies: - '@swc/core': '*' - esbuild: '*' - uglify-js: '*' - webpack: ^5.1.0 - peerDependenciesMeta: - '@swc/core': - optional: true - esbuild: - optional: true - uglify-js: - optional: true - - terser@5.46.2: - resolution: {integrity: sha512-uxfo9fPcSgLDYob/w1FuL0c99MWiJDnv+5qXSQc5+Ki5NjVNsYi66INnMFBjf6uFz6OnX12piJQPF4IpjJTNTw==} - engines: {node: '>=10'} - hasBin: true - - tiny-invariant@1.3.3: - resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} - - to-fast-properties@2.0.0: - resolution: {integrity: sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==} - engines: {node: '>=4'} - - to-regex-range@5.0.1: - resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} - engines: {node: '>=8.0'} - - tr46@1.0.1: - resolution: {integrity: sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA==} - - ts-api-utils@2.5.0: - resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} - engines: {node: '>=18.12'} - peerDependencies: - typescript: '>=4.8.4' - - tslib@2.8.1: - resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} - - type-check@0.4.0: - resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} - engines: {node: '>= 0.8.0'} - - typescript-eslint@8.21.0: - resolution: {integrity: sha512-txEKYY4XMKwPXxNkN8+AxAdX6iIJAPiJbHE/FpQccs/sxw8Lf26kqwC3cn0xkHlW8kEbLhkhCsjWuMveaY9Rxw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 - typescript: '>=4.8.4 <5.8.0' - - typescript@5.9.3: - resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} - engines: {node: '>=14.17'} - hasBin: true - - undici-types@7.19.2: - resolution: {integrity: sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==} - - update-browserslist-db@1.2.3: - resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} - hasBin: true - peerDependencies: - browserslist: '>= 4.21.0' - - uri-js@4.4.1: - resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} - - uuid@8.3.2: - resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} - deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). - hasBin: true - - watchpack@2.5.1: - resolution: {integrity: sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==} - engines: {node: '>=10.13.0'} - - webidl-conversions@4.0.2: - resolution: {integrity: sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==} - - webp-converter@2.3.3: - resolution: {integrity: sha512-2p4XvPCIQ/CbUztEFA9vdkILVrRTdMtMxFpQTxlnPc3qx14MV5wnpVvK7m6pG70QdeL+Ser0+Tp843ONwh8VbQ==} - - webpack-sources@3.4.1: - resolution: {integrity: sha512-eACpxRN02yaawnt+uUNIF7Qje6A9zArxBbcAJjK1PK3S9Ycg5jIuJ8pW4q8EMnwNZCEGltcjkRx1QzOxOkKD8A==} - engines: {node: '>=10.13.0'} - - webpack@5.105.0: - resolution: {integrity: sha512-gX/dMkRQc7QOMzgTe6KsYFM7DxeIONQSui1s0n/0xht36HvrgbxtM1xBlgx596NbpHuQU8P7QpKwrZYwUX48nw==} - engines: {node: '>=10.13.0'} - hasBin: true - peerDependencies: - webpack-cli: '*' - peerDependenciesMeta: - webpack-cli: - optional: true - - whatwg-url@7.1.0: - resolution: {integrity: sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg==} - - which@2.0.2: - resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} - engines: {node: '>= 8'} - hasBin: true - - word-wrap@1.2.5: - resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} - engines: {node: '>=0.10.0'} - - wrappy@1.0.2: - resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} - - ws@8.17.1: - resolution: {integrity: sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==} - engines: {node: '>=10.0.0'} - peerDependencies: - bufferutil: ^4.0.1 - utf-8-validate: '>=5.0.2' - peerDependenciesMeta: - bufferutil: - optional: true - utf-8-validate: - optional: true - - yallist@4.0.0: - resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} - - yauzl@2.10.0: - resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==} - - yocto-queue@0.1.0: - resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} - engines: {node: '>=10'} - - zod@4.3.6: - resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} - -snapshots: - - '@alloc/quick-lru@5.2.0': {} - - '@babel/helper-string-parser@7.27.1': {} - - '@babel/helper-validator-identifier@7.28.5': {} - - '@babel/parser@7.24.1': - dependencies: - '@babel/types': 7.24.0 - - '@babel/types@7.24.0': - dependencies: - '@babel/helper-string-parser': 7.27.1 - '@babel/helper-validator-identifier': 7.28.5 - to-fast-properties: 2.0.0 - - '@emnapi/core@1.10.0': - dependencies: - '@emnapi/wasi-threads': 1.2.1 - tslib: 2.8.1 - optional: true - - '@emnapi/runtime@1.10.0': - dependencies: - tslib: 2.8.1 - optional: true - - '@emnapi/wasi-threads@1.2.1': - dependencies: - tslib: 2.8.1 - optional: true - - '@esbuild/aix-ppc64@0.25.0': - optional: true - - '@esbuild/android-arm64@0.25.0': - optional: true - - '@esbuild/android-arm@0.25.0': - optional: true - - '@esbuild/android-x64@0.25.0': - optional: true - - '@esbuild/darwin-arm64@0.25.0': - optional: true - - '@esbuild/darwin-x64@0.25.0': - optional: true - - '@esbuild/freebsd-arm64@0.25.0': - optional: true - - '@esbuild/freebsd-x64@0.25.0': - optional: true - - '@esbuild/linux-arm64@0.25.0': - optional: true - - '@esbuild/linux-arm@0.25.0': - optional: true - - '@esbuild/linux-ia32@0.25.0': - optional: true - - '@esbuild/linux-loong64@0.25.0': - optional: true - - '@esbuild/linux-mips64el@0.25.0': - optional: true - - '@esbuild/linux-ppc64@0.25.0': - optional: true - - '@esbuild/linux-riscv64@0.25.0': - optional: true - - '@esbuild/linux-s390x@0.25.0': - optional: true - - '@esbuild/linux-x64@0.25.0': - optional: true - - '@esbuild/netbsd-arm64@0.25.0': - optional: true - - '@esbuild/netbsd-x64@0.25.0': - optional: true - - '@esbuild/openbsd-arm64@0.25.0': - optional: true - - '@esbuild/openbsd-x64@0.25.0': - optional: true - - '@esbuild/sunos-x64@0.25.0': - optional: true - - '@esbuild/win32-arm64@0.25.0': - optional: true - - '@esbuild/win32-ia32@0.25.0': - optional: true - - '@esbuild/win32-x64@0.25.0': - optional: true - - '@eslint-community/eslint-utils@4.9.1(eslint@9.19.0(jiti@2.6.1))': - dependencies: - eslint: 9.19.0(jiti@2.6.1) - eslint-visitor-keys: 3.4.3 - - '@eslint-community/regexpp@4.12.2': {} - - '@eslint/config-array@0.19.2': - dependencies: - '@eslint/object-schema': 2.1.7 - debug: 4.4.3 - minimatch: 3.1.5 - transitivePeerDependencies: - - supports-color - - '@eslint/core@0.10.0': - dependencies: - '@types/json-schema': 7.0.15 - - '@eslint/core@0.13.0': - dependencies: - '@types/json-schema': 7.0.15 - - '@eslint/eslintrc@3.3.5': - dependencies: - ajv: 6.15.0 - debug: 4.4.3 - espree: 10.4.0 - globals: 14.0.0 - ignore: 5.3.2 - import-fresh: 3.3.1 - js-yaml: 4.1.1 - minimatch: 3.1.5 - strip-json-comments: 3.1.1 - transitivePeerDependencies: - - supports-color - - '@eslint/js@9.19.0': {} - - '@eslint/object-schema@2.1.7': {} - - '@eslint/plugin-kit@0.2.8': - dependencies: - '@eslint/core': 0.13.0 - levn: 0.4.1 - - '@humanfs/core@0.19.2': - dependencies: - '@humanfs/types': 0.15.0 - - '@humanfs/node@0.16.8': - dependencies: - '@humanfs/core': 0.19.2 - '@humanfs/types': 0.15.0 - '@humanwhocodes/retry': 0.4.3 - - '@humanfs/types@0.15.0': {} - - '@humanwhocodes/module-importer@1.0.1': {} - - '@humanwhocodes/retry@0.4.3': {} - - '@jridgewell/gen-mapping@0.3.13': - dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 - '@jridgewell/trace-mapping': 0.3.31 - - '@jridgewell/remapping@2.3.5': - dependencies: - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.31 - - '@jridgewell/resolve-uri@3.1.2': {} - - '@jridgewell/source-map@0.3.11': - dependencies: - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.31 - - '@jridgewell/sourcemap-codec@1.5.5': {} - - '@jridgewell/trace-mapping@0.3.31': - dependencies: - '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.5 - - '@mediabunny/aac-encoder@1.42.0(mediabunny@1.42.0)': - dependencies: - mediabunny: 1.42.0 - - '@mediabunny/flac-encoder@1.42.0(mediabunny@1.42.0)': - dependencies: - mediabunny: 1.42.0 - - '@mediabunny/mp3-encoder@1.42.0(mediabunny@1.42.0)': - dependencies: - mediabunny: 1.42.0 - - '@module-federation/error-codes@0.22.0': {} - - '@module-federation/runtime-core@0.22.0': - dependencies: - '@module-federation/error-codes': 0.22.0 - '@module-federation/sdk': 0.22.0 - - '@module-federation/runtime-tools@0.22.0': - dependencies: - '@module-federation/runtime': 0.22.0 - '@module-federation/webpack-bundler-runtime': 0.22.0 - - '@module-federation/runtime@0.22.0': - dependencies: - '@module-federation/error-codes': 0.22.0 - '@module-federation/runtime-core': 0.22.0 - '@module-federation/sdk': 0.22.0 - - '@module-federation/sdk@0.22.0': {} - - '@module-federation/webpack-bundler-runtime@0.22.0': - dependencies: - '@module-federation/runtime': 0.22.0 - '@module-federation/sdk': 0.22.0 - - '@napi-rs/wasm-runtime@1.0.7': - dependencies: - '@emnapi/core': 1.10.0 - '@emnapi/runtime': 1.10.0 - '@tybys/wasm-util': 0.10.2 - optional: true - - '@nodelib/fs.scandir@2.1.5': - dependencies: - '@nodelib/fs.stat': 2.0.5 - run-parallel: 1.2.0 - - '@nodelib/fs.stat@2.0.5': {} - - '@nodelib/fs.walk@1.2.8': - dependencies: - '@nodelib/fs.scandir': 2.1.5 - fastq: 1.20.1 - - '@remotion/bundler@4.0.454(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': - dependencies: - '@remotion/media-parser': 4.0.454 - '@remotion/studio': 4.0.454(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@remotion/studio-shared': 4.0.454(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@rspack/core': 1.7.6 - '@rspack/plugin-react-refresh': 1.6.1(react-refresh@0.18.0) - esbuild: 0.25.0 - loader-utils: 2.0.4 - postcss: 8.5.10 - postcss-value-parser: 4.2.0 - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) - react-refresh: 0.18.0 - remotion: 4.0.454(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - source-map: 0.7.3 - style-loader: 4.0.0(webpack@5.105.0) - webpack: 5.105.0(esbuild@0.25.0) - transitivePeerDependencies: - - '@swc/core' - - '@swc/helpers' - - bufferutil - - supports-color - - uglify-js - - utf-8-validate - - webpack-cli - - webpack-hot-middleware - - '@remotion/cli@4.0.454(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': - dependencies: - '@remotion/bundler': 4.0.454(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@remotion/media-utils': 4.0.454(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@remotion/player': 4.0.454(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@remotion/renderer': 4.0.454(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@remotion/studio': 4.0.454(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@remotion/studio-server': 4.0.454(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@remotion/studio-shared': 4.0.454(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - dotenv: 17.3.1 - minimist: 1.2.6 - prompts: 2.4.2 - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) - remotion: 4.0.454(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - transitivePeerDependencies: - - '@swc/core' - - '@swc/helpers' - - bufferutil - - supports-color - - uglify-js - - utf-8-validate - - webpack-cli - - webpack-hot-middleware - - '@remotion/compositor-darwin-arm64@4.0.454': - optional: true - - '@remotion/compositor-darwin-x64@4.0.454': - optional: true - - '@remotion/compositor-linux-arm64-gnu@4.0.454': - optional: true - - '@remotion/compositor-linux-arm64-musl@4.0.454': - optional: true - - '@remotion/compositor-linux-x64-gnu@4.0.454': - optional: true - - '@remotion/compositor-linux-x64-musl@4.0.454': - optional: true - - '@remotion/compositor-win32-x64-msvc@4.0.454': - optional: true - - '@remotion/eslint-config-flat@4.0.454(eslint@9.19.0(jiti@2.6.1))(typescript@5.9.3)': - dependencies: - eslint: 9.19.0(jiti@2.6.1) - typescript-eslint: 8.21.0(eslint@9.19.0(jiti@2.6.1))(typescript@5.9.3) - transitivePeerDependencies: - - supports-color - - typescript - - '@remotion/licensing@4.0.454': {} - - '@remotion/media-parser@4.0.454': {} - - '@remotion/media-utils@4.0.454(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': - dependencies: - mediabunny: 1.42.0 - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) - remotion: 4.0.454(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - - '@remotion/player@4.0.454(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': - dependencies: - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) - remotion: 4.0.454(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - - '@remotion/renderer@4.0.454(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': - dependencies: - '@remotion/licensing': 4.0.454 - '@remotion/streaming': 4.0.454 - execa: 5.1.1 - extract-zip: 2.0.1 - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) - remotion: 4.0.454(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - source-map: 0.8.0-beta.0 - ws: 8.17.1 - optionalDependencies: - '@remotion/compositor-darwin-arm64': 4.0.454 - '@remotion/compositor-darwin-x64': 4.0.454 - '@remotion/compositor-linux-arm64-gnu': 4.0.454 - '@remotion/compositor-linux-arm64-musl': 4.0.454 - '@remotion/compositor-linux-x64-gnu': 4.0.454 - '@remotion/compositor-linux-x64-musl': 4.0.454 - '@remotion/compositor-win32-x64-msvc': 4.0.454 - transitivePeerDependencies: - - bufferutil - - supports-color - - utf-8-validate - - '@remotion/streaming@4.0.454': {} - - '@remotion/studio-server@4.0.454(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': - dependencies: - '@babel/parser': 7.24.1 - '@babel/types': 7.24.0 - '@remotion/bundler': 4.0.454(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@remotion/renderer': 4.0.454(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@remotion/studio-shared': 4.0.454(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - memfs: 3.4.3 - open: 8.4.2 - prettier: 3.8.1 - recast: 0.23.11 - remotion: 4.0.454(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - semver: 7.5.3 - source-map: 0.7.3 - transitivePeerDependencies: - - '@swc/core' - - '@swc/helpers' - - bufferutil - - react - - react-dom - - supports-color - - uglify-js - - utf-8-validate - - webpack-cli - - webpack-hot-middleware - - '@remotion/studio-shared@4.0.454(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': - dependencies: - remotion: 4.0.454(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - transitivePeerDependencies: - - react - - react-dom - - '@remotion/studio@4.0.454(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': - dependencies: - '@remotion/media-utils': 4.0.454(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@remotion/player': 4.0.454(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@remotion/renderer': 4.0.454(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@remotion/studio-shared': 4.0.454(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@remotion/web-renderer': 4.0.454(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@remotion/zod-types': 4.0.454(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(zod@4.3.6) - mediabunny: 1.42.0 - memfs: 3.4.3 - open: 8.4.2 - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) - remotion: 4.0.454(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - semver: 7.5.3 - source-map: 0.7.3 - zod: 4.3.6 - transitivePeerDependencies: - - bufferutil - - supports-color - - utf-8-validate - - '@remotion/tailwind-v4@4.0.454(@remotion/bundler@4.0.454(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(webpack@5.105.0)': - dependencies: - '@remotion/bundler': 4.0.454(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@tailwindcss/webpack': 4.2.0(webpack@5.105.0) - style-loader: 4.0.0(webpack@5.105.0) - tailwindcss: 4.2.0 - transitivePeerDependencies: - - webpack - - '@remotion/web-renderer@4.0.454(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': - dependencies: - '@mediabunny/aac-encoder': 1.42.0(mediabunny@1.42.0) - '@mediabunny/flac-encoder': 1.42.0(mediabunny@1.42.0) - '@mediabunny/mp3-encoder': 1.42.0(mediabunny@1.42.0) - '@remotion/licensing': 4.0.454 - mediabunny: 1.42.0 - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) - remotion: 4.0.454(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - - '@remotion/zod-types@4.0.454(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(zod@4.3.6)': - dependencies: - remotion: 4.0.454(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - zod: 4.3.6 - transitivePeerDependencies: - - react - - react-dom - - '@rspack/binding-darwin-arm64@1.7.6': - optional: true - - '@rspack/binding-darwin-x64@1.7.6': - optional: true - - '@rspack/binding-linux-arm64-gnu@1.7.6': - optional: true - - '@rspack/binding-linux-arm64-musl@1.7.6': - optional: true - - '@rspack/binding-linux-x64-gnu@1.7.6': - optional: true - - '@rspack/binding-linux-x64-musl@1.7.6': - optional: true - - '@rspack/binding-wasm32-wasi@1.7.6': - dependencies: - '@napi-rs/wasm-runtime': 1.0.7 - optional: true - - '@rspack/binding-win32-arm64-msvc@1.7.6': - optional: true - - '@rspack/binding-win32-ia32-msvc@1.7.6': - optional: true - - '@rspack/binding-win32-x64-msvc@1.7.6': - optional: true - - '@rspack/binding@1.7.6': - optionalDependencies: - '@rspack/binding-darwin-arm64': 1.7.6 - '@rspack/binding-darwin-x64': 1.7.6 - '@rspack/binding-linux-arm64-gnu': 1.7.6 - '@rspack/binding-linux-arm64-musl': 1.7.6 - '@rspack/binding-linux-x64-gnu': 1.7.6 - '@rspack/binding-linux-x64-musl': 1.7.6 - '@rspack/binding-wasm32-wasi': 1.7.6 - '@rspack/binding-win32-arm64-msvc': 1.7.6 - '@rspack/binding-win32-ia32-msvc': 1.7.6 - '@rspack/binding-win32-x64-msvc': 1.7.6 - - '@rspack/core@1.7.6': - dependencies: - '@module-federation/runtime-tools': 0.22.0 - '@rspack/binding': 1.7.6 - '@rspack/lite-tapable': 1.1.0 - - '@rspack/lite-tapable@1.1.0': {} - - '@rspack/plugin-react-refresh@1.6.1(react-refresh@0.18.0)': - dependencies: - error-stack-parser: 2.1.4 - html-entities: 2.6.0 - react-refresh: 0.18.0 - - '@tailwindcss/node@4.2.0': - dependencies: - '@jridgewell/remapping': 2.3.5 - enhanced-resolve: 5.21.0 - jiti: 2.6.1 - lightningcss: 1.31.1 - magic-string: 0.30.21 - source-map-js: 1.2.1 - tailwindcss: 4.2.0 - - '@tailwindcss/oxide-android-arm64@4.2.0': - optional: true - - '@tailwindcss/oxide-darwin-arm64@4.2.0': - optional: true - - '@tailwindcss/oxide-darwin-x64@4.2.0': - optional: true - - '@tailwindcss/oxide-freebsd-x64@4.2.0': - optional: true - - '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.0': - optional: true - - '@tailwindcss/oxide-linux-arm64-gnu@4.2.0': - optional: true - - '@tailwindcss/oxide-linux-arm64-musl@4.2.0': - optional: true - - '@tailwindcss/oxide-linux-x64-gnu@4.2.0': - optional: true - - '@tailwindcss/oxide-linux-x64-musl@4.2.0': - optional: true - - '@tailwindcss/oxide-wasm32-wasi@4.2.0': - optional: true - - '@tailwindcss/oxide-win32-arm64-msvc@4.2.0': - optional: true - - '@tailwindcss/oxide-win32-x64-msvc@4.2.0': - optional: true - - '@tailwindcss/oxide@4.2.0': - optionalDependencies: - '@tailwindcss/oxide-android-arm64': 4.2.0 - '@tailwindcss/oxide-darwin-arm64': 4.2.0 - '@tailwindcss/oxide-darwin-x64': 4.2.0 - '@tailwindcss/oxide-freebsd-x64': 4.2.0 - '@tailwindcss/oxide-linux-arm-gnueabihf': 4.2.0 - '@tailwindcss/oxide-linux-arm64-gnu': 4.2.0 - '@tailwindcss/oxide-linux-arm64-musl': 4.2.0 - '@tailwindcss/oxide-linux-x64-gnu': 4.2.0 - '@tailwindcss/oxide-linux-x64-musl': 4.2.0 - '@tailwindcss/oxide-wasm32-wasi': 4.2.0 - '@tailwindcss/oxide-win32-arm64-msvc': 4.2.0 - '@tailwindcss/oxide-win32-x64-msvc': 4.2.0 - - '@tailwindcss/webpack@4.2.0(webpack@5.105.0)': - dependencies: - '@alloc/quick-lru': 5.2.0 - '@tailwindcss/node': 4.2.0 - '@tailwindcss/oxide': 4.2.0 - tailwindcss: 4.2.0 - webpack: 5.105.0(esbuild@0.25.0) - - '@tybys/wasm-util@0.10.2': - dependencies: - tslib: 2.8.1 - optional: true - - '@types/dom-mediacapture-transform@0.1.11': - dependencies: - '@types/dom-webcodecs': 0.1.13 - - '@types/dom-webcodecs@0.1.13': {} - - '@types/eslint-scope@3.7.7': - dependencies: - '@types/eslint': 9.6.1 - '@types/estree': 1.0.8 - - '@types/eslint@9.6.1': - dependencies: - '@types/estree': 1.0.8 - '@types/json-schema': 7.0.15 - - '@types/estree@1.0.8': {} - - '@types/json-schema@7.0.15': {} - - '@types/node@25.6.0': - dependencies: - undici-types: 7.19.2 - - '@types/react@19.2.7': - dependencies: - csstype: 3.2.3 - - '@types/web@0.0.166': {} - - '@types/yauzl@2.10.3': - dependencies: - '@types/node': 25.6.0 - optional: true - - '@typescript-eslint/eslint-plugin@8.21.0(@typescript-eslint/parser@8.21.0(eslint@9.19.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.19.0(jiti@2.6.1))(typescript@5.9.3)': - dependencies: - '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.21.0(eslint@9.19.0(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/scope-manager': 8.21.0 - '@typescript-eslint/type-utils': 8.21.0(eslint@9.19.0(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/utils': 8.21.0(eslint@9.19.0(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.21.0 - eslint: 9.19.0(jiti@2.6.1) - graphemer: 1.4.0 - ignore: 5.3.2 - natural-compare: 1.4.0 - ts-api-utils: 2.5.0(typescript@5.9.3) - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/parser@8.21.0(eslint@9.19.0(jiti@2.6.1))(typescript@5.9.3)': - dependencies: - '@typescript-eslint/scope-manager': 8.21.0 - '@typescript-eslint/types': 8.21.0 - '@typescript-eslint/typescript-estree': 8.21.0(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.21.0 - debug: 4.4.3 - eslint: 9.19.0(jiti@2.6.1) - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/scope-manager@8.21.0': - dependencies: - '@typescript-eslint/types': 8.21.0 - '@typescript-eslint/visitor-keys': 8.21.0 - - '@typescript-eslint/type-utils@8.21.0(eslint@9.19.0(jiti@2.6.1))(typescript@5.9.3)': - dependencies: - '@typescript-eslint/typescript-estree': 8.21.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.21.0(eslint@9.19.0(jiti@2.6.1))(typescript@5.9.3) - debug: 4.4.3 - eslint: 9.19.0(jiti@2.6.1) - ts-api-utils: 2.5.0(typescript@5.9.3) - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/types@8.21.0': {} - - '@typescript-eslint/typescript-estree@8.21.0(typescript@5.9.3)': - dependencies: - '@typescript-eslint/types': 8.21.0 - '@typescript-eslint/visitor-keys': 8.21.0 - debug: 4.4.3 - fast-glob: 3.3.3 - is-glob: 4.0.3 - minimatch: 9.0.9 - semver: 7.7.4 - ts-api-utils: 2.5.0(typescript@5.9.3) - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/utils@8.21.0(eslint@9.19.0(jiti@2.6.1))(typescript@5.9.3)': - dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.19.0(jiti@2.6.1)) - '@typescript-eslint/scope-manager': 8.21.0 - '@typescript-eslint/types': 8.21.0 - '@typescript-eslint/typescript-estree': 8.21.0(typescript@5.9.3) - eslint: 9.19.0(jiti@2.6.1) - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/visitor-keys@8.21.0': - dependencies: - '@typescript-eslint/types': 8.21.0 - eslint-visitor-keys: 4.2.1 - - '@webassemblyjs/ast@1.14.1': - dependencies: - '@webassemblyjs/helper-numbers': 1.13.2 - '@webassemblyjs/helper-wasm-bytecode': 1.13.2 - - '@webassemblyjs/floating-point-hex-parser@1.13.2': {} - - '@webassemblyjs/helper-api-error@1.13.2': {} - - '@webassemblyjs/helper-buffer@1.14.1': {} - - '@webassemblyjs/helper-numbers@1.13.2': - dependencies: - '@webassemblyjs/floating-point-hex-parser': 1.13.2 - '@webassemblyjs/helper-api-error': 1.13.2 - '@xtuc/long': 4.2.2 - - '@webassemblyjs/helper-wasm-bytecode@1.13.2': {} - - '@webassemblyjs/helper-wasm-section@1.14.1': - dependencies: - '@webassemblyjs/ast': 1.14.1 - '@webassemblyjs/helper-buffer': 1.14.1 - '@webassemblyjs/helper-wasm-bytecode': 1.13.2 - '@webassemblyjs/wasm-gen': 1.14.1 - - '@webassemblyjs/ieee754@1.13.2': - dependencies: - '@xtuc/ieee754': 1.2.0 - - '@webassemblyjs/leb128@1.13.2': - dependencies: - '@xtuc/long': 4.2.2 - - '@webassemblyjs/utf8@1.13.2': {} - - '@webassemblyjs/wasm-edit@1.14.1': - dependencies: - '@webassemblyjs/ast': 1.14.1 - '@webassemblyjs/helper-buffer': 1.14.1 - '@webassemblyjs/helper-wasm-bytecode': 1.13.2 - '@webassemblyjs/helper-wasm-section': 1.14.1 - '@webassemblyjs/wasm-gen': 1.14.1 - '@webassemblyjs/wasm-opt': 1.14.1 - '@webassemblyjs/wasm-parser': 1.14.1 - '@webassemblyjs/wast-printer': 1.14.1 - - '@webassemblyjs/wasm-gen@1.14.1': - dependencies: - '@webassemblyjs/ast': 1.14.1 - '@webassemblyjs/helper-wasm-bytecode': 1.13.2 - '@webassemblyjs/ieee754': 1.13.2 - '@webassemblyjs/leb128': 1.13.2 - '@webassemblyjs/utf8': 1.13.2 - - '@webassemblyjs/wasm-opt@1.14.1': - dependencies: - '@webassemblyjs/ast': 1.14.1 - '@webassemblyjs/helper-buffer': 1.14.1 - '@webassemblyjs/wasm-gen': 1.14.1 - '@webassemblyjs/wasm-parser': 1.14.1 - - '@webassemblyjs/wasm-parser@1.14.1': - dependencies: - '@webassemblyjs/ast': 1.14.1 - '@webassemblyjs/helper-api-error': 1.13.2 - '@webassemblyjs/helper-wasm-bytecode': 1.13.2 - '@webassemblyjs/ieee754': 1.13.2 - '@webassemblyjs/leb128': 1.13.2 - '@webassemblyjs/utf8': 1.13.2 - - '@webassemblyjs/wast-printer@1.14.1': - dependencies: - '@webassemblyjs/ast': 1.14.1 - '@xtuc/long': 4.2.2 - - '@xtuc/ieee754@1.2.0': {} - - '@xtuc/long@4.2.2': {} - - acorn-import-phases@1.0.4(acorn@8.16.0): - dependencies: - acorn: 8.16.0 - - acorn-jsx@5.3.2(acorn@8.16.0): - dependencies: - acorn: 8.16.0 - - acorn@8.16.0: {} - - ajv-formats@2.1.1(ajv@8.20.0): - optionalDependencies: - ajv: 8.20.0 - - ajv-keywords@5.1.0(ajv@8.20.0): - dependencies: - ajv: 8.20.0 - fast-deep-equal: 3.1.3 - - ajv@6.15.0: - dependencies: - fast-deep-equal: 3.1.3 - fast-json-stable-stringify: 2.1.0 - json-schema-traverse: 0.4.1 - uri-js: 4.4.1 - - ajv@8.20.0: - dependencies: - fast-deep-equal: 3.1.3 - fast-uri: 3.1.1 - json-schema-traverse: 1.0.0 - require-from-string: 2.0.2 - - ansi-styles@4.3.0: - dependencies: - color-convert: 2.0.1 - - argparse@2.0.1: {} - - ast-types@0.16.1: - dependencies: - tslib: 2.8.1 - - balanced-match@1.0.2: {} - - baseline-browser-mapping@2.10.27: {} - - big.js@5.2.2: {} - - brace-expansion@1.1.14: - dependencies: - balanced-match: 1.0.2 - concat-map: 0.0.1 - - brace-expansion@2.1.0: - dependencies: - balanced-match: 1.0.2 - - braces@3.0.3: - dependencies: - fill-range: 7.1.1 - - browserslist@4.28.2: - dependencies: - baseline-browser-mapping: 2.10.27 - caniuse-lite: 1.0.30001791 - electron-to-chromium: 1.5.349 - node-releases: 2.0.38 - update-browserslist-db: 1.2.3(browserslist@4.28.2) - - buffer-crc32@0.2.13: {} - - buffer-from@1.1.2: {} - - callsites@3.1.0: {} - - caniuse-lite@1.0.30001791: {} - - chalk@4.1.2: - dependencies: - ansi-styles: 4.3.0 - supports-color: 7.2.0 - - chrome-trace-event@1.0.4: {} - - color-convert@2.0.1: - dependencies: - color-name: 1.1.4 - - color-name@1.1.4: {} - - commander@2.20.3: {} - - concat-map@0.0.1: {} - - cross-spawn@7.0.6: - dependencies: - path-key: 3.1.1 - shebang-command: 2.0.0 - which: 2.0.2 - - csstype@3.2.3: {} - - debug@4.4.3: - dependencies: - ms: 2.1.3 - - deep-is@0.1.4: {} - - define-lazy-prop@2.0.0: {} - - detect-libc@2.1.2: {} - - dotenv@17.3.1: {} - - electron-to-chromium@1.5.349: {} - - emojis-list@3.0.0: {} - - end-of-stream@1.4.5: - dependencies: - once: 1.4.0 - - enhanced-resolve@5.21.0: - dependencies: - graceful-fs: 4.2.11 - tapable: 2.3.3 - - error-stack-parser@2.1.4: - dependencies: - stackframe: 1.3.4 - - es-module-lexer@2.1.0: {} - - esbuild@0.25.0: - optionalDependencies: - '@esbuild/aix-ppc64': 0.25.0 - '@esbuild/android-arm': 0.25.0 - '@esbuild/android-arm64': 0.25.0 - '@esbuild/android-x64': 0.25.0 - '@esbuild/darwin-arm64': 0.25.0 - '@esbuild/darwin-x64': 0.25.0 - '@esbuild/freebsd-arm64': 0.25.0 - '@esbuild/freebsd-x64': 0.25.0 - '@esbuild/linux-arm': 0.25.0 - '@esbuild/linux-arm64': 0.25.0 - '@esbuild/linux-ia32': 0.25.0 - '@esbuild/linux-loong64': 0.25.0 - '@esbuild/linux-mips64el': 0.25.0 - '@esbuild/linux-ppc64': 0.25.0 - '@esbuild/linux-riscv64': 0.25.0 - '@esbuild/linux-s390x': 0.25.0 - '@esbuild/linux-x64': 0.25.0 - '@esbuild/netbsd-arm64': 0.25.0 - '@esbuild/netbsd-x64': 0.25.0 - '@esbuild/openbsd-arm64': 0.25.0 - '@esbuild/openbsd-x64': 0.25.0 - '@esbuild/sunos-x64': 0.25.0 - '@esbuild/win32-arm64': 0.25.0 - '@esbuild/win32-ia32': 0.25.0 - '@esbuild/win32-x64': 0.25.0 - - escalade@3.2.0: {} - - escape-string-regexp@4.0.0: {} - - eslint-scope@5.1.1: - dependencies: - esrecurse: 4.3.0 - estraverse: 4.3.0 - - eslint-scope@8.4.0: - dependencies: - esrecurse: 4.3.0 - estraverse: 5.3.0 - - eslint-visitor-keys@3.4.3: {} - - eslint-visitor-keys@4.2.1: {} - - eslint@9.19.0(jiti@2.6.1): - dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.19.0(jiti@2.6.1)) - '@eslint-community/regexpp': 4.12.2 - '@eslint/config-array': 0.19.2 - '@eslint/core': 0.10.0 - '@eslint/eslintrc': 3.3.5 - '@eslint/js': 9.19.0 - '@eslint/plugin-kit': 0.2.8 - '@humanfs/node': 0.16.8 - '@humanwhocodes/module-importer': 1.0.1 - '@humanwhocodes/retry': 0.4.3 - '@types/estree': 1.0.8 - '@types/json-schema': 7.0.15 - ajv: 6.15.0 - chalk: 4.1.2 - cross-spawn: 7.0.6 - debug: 4.4.3 - escape-string-regexp: 4.0.0 - eslint-scope: 8.4.0 - eslint-visitor-keys: 4.2.1 - espree: 10.4.0 - esquery: 1.7.0 - esutils: 2.0.3 - fast-deep-equal: 3.1.3 - file-entry-cache: 8.0.0 - find-up: 5.0.0 - glob-parent: 6.0.2 - ignore: 5.3.2 - imurmurhash: 0.1.4 - is-glob: 4.0.3 - json-stable-stringify-without-jsonify: 1.0.1 - lodash.merge: 4.6.2 - minimatch: 3.1.5 - natural-compare: 1.4.0 - optionator: 0.9.4 - optionalDependencies: - jiti: 2.6.1 - transitivePeerDependencies: - - supports-color - - espree@10.4.0: - dependencies: - acorn: 8.16.0 - acorn-jsx: 5.3.2(acorn@8.16.0) - eslint-visitor-keys: 4.2.1 - - esprima@4.0.1: {} - - esquery@1.7.0: - dependencies: - estraverse: 5.3.0 - - esrecurse@4.3.0: - dependencies: - estraverse: 5.3.0 - - estraverse@4.3.0: {} - - estraverse@5.3.0: {} - - esutils@2.0.3: {} - - events@3.3.0: {} - - execa@5.1.1: - dependencies: - cross-spawn: 7.0.6 - get-stream: 6.0.1 - human-signals: 2.1.0 - is-stream: 2.0.1 - merge-stream: 2.0.0 - npm-run-path: 4.0.1 - onetime: 5.1.2 - signal-exit: 3.0.7 - strip-final-newline: 2.0.0 - - extract-zip@2.0.1: - dependencies: - debug: 4.4.3 - get-stream: 5.2.0 - yauzl: 2.10.0 - optionalDependencies: - '@types/yauzl': 2.10.3 - transitivePeerDependencies: - - supports-color - - fast-deep-equal@3.1.3: {} - - fast-glob@3.3.3: - dependencies: - '@nodelib/fs.stat': 2.0.5 - '@nodelib/fs.walk': 1.2.8 - glob-parent: 5.1.2 - merge2: 1.4.1 - micromatch: 4.0.8 - - fast-json-stable-stringify@2.1.0: {} - - fast-levenshtein@2.0.6: {} - - fast-uri@3.1.1: {} - - fastq@1.20.1: - dependencies: - reusify: 1.1.0 - - fd-slicer@1.1.0: - dependencies: - pend: 1.2.0 - - file-entry-cache@8.0.0: - dependencies: - flat-cache: 4.0.1 - - fill-range@7.1.1: - dependencies: - to-regex-range: 5.0.1 - - find-up@5.0.0: - dependencies: - locate-path: 6.0.0 - path-exists: 4.0.0 - - flat-cache@4.0.1: - dependencies: - flatted: 3.4.2 - keyv: 4.5.4 - - flatted@3.4.2: {} - - fs-monkey@1.0.3: {} - - get-stream@5.2.0: - dependencies: - pump: 3.0.4 - - get-stream@6.0.1: {} - - glob-parent@5.1.2: - dependencies: - is-glob: 4.0.3 - - glob-parent@6.0.2: - dependencies: - is-glob: 4.0.3 - - glob-to-regexp@0.4.1: {} - - globals@14.0.0: {} - - graceful-fs@4.2.11: {} - - graphemer@1.4.0: {} - - has-flag@4.0.0: {} - - html-entities@2.6.0: {} - - human-signals@2.1.0: {} - - ignore@5.3.2: {} - - import-fresh@3.3.1: - dependencies: - parent-module: 1.0.1 - resolve-from: 4.0.0 - - imurmurhash@0.1.4: {} - - is-docker@2.2.1: {} - - is-extglob@2.1.1: {} - - is-glob@4.0.3: - dependencies: - is-extglob: 2.1.1 - - is-number@7.0.0: {} - - is-stream@2.0.1: {} - - is-wsl@2.2.0: - dependencies: - is-docker: 2.2.1 - - isexe@2.0.0: {} - - jest-worker@27.5.1: - dependencies: - '@types/node': 25.6.0 - merge-stream: 2.0.0 - supports-color: 8.1.1 - - jiti@2.6.1: {} - - js-yaml@4.1.1: - dependencies: - argparse: 2.0.1 - - json-buffer@3.0.1: {} - - json-parse-even-better-errors@2.3.1: {} - - json-schema-traverse@0.4.1: {} - - json-schema-traverse@1.0.0: {} - - json-stable-stringify-without-jsonify@1.0.1: {} - - json5@2.2.3: {} - - keyv@4.5.4: - dependencies: - json-buffer: 3.0.1 - - kleur@3.0.3: {} - - levn@0.4.1: - dependencies: - prelude-ls: 1.2.1 - type-check: 0.4.0 - - lightningcss-android-arm64@1.31.1: - optional: true - - lightningcss-darwin-arm64@1.31.1: - optional: true - - lightningcss-darwin-x64@1.31.1: - optional: true - - lightningcss-freebsd-x64@1.31.1: - optional: true - - lightningcss-linux-arm-gnueabihf@1.31.1: - optional: true - - lightningcss-linux-arm64-gnu@1.31.1: - optional: true - - lightningcss-linux-arm64-musl@1.31.1: - optional: true - - lightningcss-linux-x64-gnu@1.31.1: - optional: true - - lightningcss-linux-x64-musl@1.31.1: - optional: true - - lightningcss-win32-arm64-msvc@1.31.1: - optional: true - - lightningcss-win32-x64-msvc@1.31.1: - optional: true - - lightningcss@1.31.1: - dependencies: - detect-libc: 2.1.2 - optionalDependencies: - lightningcss-android-arm64: 1.31.1 - lightningcss-darwin-arm64: 1.31.1 - lightningcss-darwin-x64: 1.31.1 - lightningcss-freebsd-x64: 1.31.1 - lightningcss-linux-arm-gnueabihf: 1.31.1 - lightningcss-linux-arm64-gnu: 1.31.1 - lightningcss-linux-arm64-musl: 1.31.1 - lightningcss-linux-x64-gnu: 1.31.1 - lightningcss-linux-x64-musl: 1.31.1 - lightningcss-win32-arm64-msvc: 1.31.1 - lightningcss-win32-x64-msvc: 1.31.1 - - loader-runner@4.3.2: {} - - loader-utils@2.0.4: - dependencies: - big.js: 5.2.2 - emojis-list: 3.0.0 - json5: 2.2.3 - - locate-path@6.0.0: - dependencies: - p-locate: 5.0.0 - - lodash.merge@4.6.2: {} - - lodash.sortby@4.7.0: {} - - lru-cache@6.0.0: - dependencies: - yallist: 4.0.0 - - magic-string@0.30.21: - dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 - - mediabunny@1.42.0: - dependencies: - '@types/dom-mediacapture-transform': 0.1.11 - '@types/dom-webcodecs': 0.1.13 - - memfs@3.4.3: - dependencies: - fs-monkey: 1.0.3 - - merge-stream@2.0.0: {} - - merge2@1.4.1: {} - - micromatch@4.0.8: - dependencies: - braces: 3.0.3 - picomatch: 2.3.2 - - mime-db@1.52.0: {} - - mime-types@2.1.35: - dependencies: - mime-db: 1.52.0 - - mimic-fn@2.1.0: {} - - minimatch@3.1.5: - dependencies: - brace-expansion: 1.1.14 - - minimatch@9.0.9: - dependencies: - brace-expansion: 2.1.0 - - minimist@1.2.6: {} - - ms@2.1.3: {} - - nanoid@3.3.12: {} - - natural-compare@1.4.0: {} - - neo-async@2.6.2: {} - - node-releases@2.0.38: {} - - npm-run-path@4.0.1: - dependencies: - path-key: 3.1.1 - - once@1.4.0: - dependencies: - wrappy: 1.0.2 - - onetime@5.1.2: - dependencies: - mimic-fn: 2.1.0 - - open@8.4.2: - dependencies: - define-lazy-prop: 2.0.0 - is-docker: 2.2.1 - is-wsl: 2.2.0 - - optionator@0.9.4: - dependencies: - deep-is: 0.1.4 - fast-levenshtein: 2.0.6 - levn: 0.4.1 - prelude-ls: 1.2.1 - type-check: 0.4.0 - word-wrap: 1.2.5 - - p-limit@3.1.0: - dependencies: - yocto-queue: 0.1.0 - - p-locate@5.0.0: - dependencies: - p-limit: 3.1.0 - - parent-module@1.0.1: - dependencies: - callsites: 3.1.0 - - path-exists@4.0.0: {} - - path-key@3.1.1: {} - - pend@1.2.0: {} - - picocolors@1.1.1: {} - - picomatch@2.3.2: {} - - postcss-value-parser@4.2.0: {} - - postcss@8.5.10: - dependencies: - nanoid: 3.3.12 - picocolors: 1.1.1 - source-map-js: 1.2.1 - - prelude-ls@1.2.1: {} - - prettier@3.8.1: {} - - prompts@2.4.2: - dependencies: - kleur: 3.0.3 - sisteransi: 1.0.5 - - pump@3.0.4: - dependencies: - end-of-stream: 1.4.5 - once: 1.4.0 - - punycode@2.3.1: {} - - queue-microtask@1.2.3: {} - - react-dom@19.2.3(react@19.2.3): - dependencies: - react: 19.2.3 - scheduler: 0.27.0 - - react-refresh@0.18.0: {} - - react@19.2.3: {} - - recast@0.23.11: - dependencies: - ast-types: 0.16.1 - esprima: 4.0.1 - source-map: 0.6.1 - tiny-invariant: 1.3.3 - tslib: 2.8.1 - - remotion@4.0.454(react-dom@19.2.3(react@19.2.3))(react@19.2.3): - dependencies: - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) - - require-from-string@2.0.2: {} - - resolve-from@4.0.0: {} - - reusify@1.1.0: {} - - run-parallel@1.2.0: - dependencies: - queue-microtask: 1.2.3 - - scheduler@0.27.0: {} - - schema-utils@4.3.3: - dependencies: - '@types/json-schema': 7.0.15 - ajv: 8.20.0 - ajv-formats: 2.1.1(ajv@8.20.0) - ajv-keywords: 5.1.0(ajv@8.20.0) - - semver@7.5.3: - dependencies: - lru-cache: 6.0.0 - - semver@7.7.4: {} - - shebang-command@2.0.0: - dependencies: - shebang-regex: 3.0.0 - - shebang-regex@3.0.0: {} - - signal-exit@3.0.7: {} - - sisteransi@1.0.5: {} - - source-map-js@1.2.1: {} - - source-map-support@0.5.21: - dependencies: - buffer-from: 1.1.2 - source-map: 0.6.1 - - source-map@0.6.1: {} - - source-map@0.7.3: {} - - source-map@0.8.0-beta.0: - dependencies: - whatwg-url: 7.1.0 - - stackframe@1.3.4: {} - - strip-final-newline@2.0.0: {} - - strip-json-comments@3.1.1: {} - - style-loader@4.0.0(webpack@5.105.0): - dependencies: - webpack: 5.105.0(esbuild@0.25.0) - - supports-color@7.2.0: - dependencies: - has-flag: 4.0.0 - - supports-color@8.1.1: - dependencies: - has-flag: 4.0.0 - - tailwindcss@4.0.0: {} - - tailwindcss@4.2.0: {} - - tapable@2.3.3: {} - - terser-webpack-plugin@5.5.0(esbuild@0.25.0)(webpack@5.105.0): - dependencies: - '@jridgewell/trace-mapping': 0.3.31 - jest-worker: 27.5.1 - schema-utils: 4.3.3 - terser: 5.46.2 - webpack: 5.105.0(esbuild@0.25.0) - optionalDependencies: - esbuild: 0.25.0 - - terser@5.46.2: - dependencies: - '@jridgewell/source-map': 0.3.11 - acorn: 8.16.0 - commander: 2.20.3 - source-map-support: 0.5.21 - - tiny-invariant@1.3.3: {} - - to-fast-properties@2.0.0: {} - - to-regex-range@5.0.1: - dependencies: - is-number: 7.0.0 - - tr46@1.0.1: - dependencies: - punycode: 2.3.1 - - ts-api-utils@2.5.0(typescript@5.9.3): - dependencies: - typescript: 5.9.3 - - tslib@2.8.1: {} - - type-check@0.4.0: - dependencies: - prelude-ls: 1.2.1 - - typescript-eslint@8.21.0(eslint@9.19.0(jiti@2.6.1))(typescript@5.9.3): - dependencies: - '@typescript-eslint/eslint-plugin': 8.21.0(@typescript-eslint/parser@8.21.0(eslint@9.19.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.19.0(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/parser': 8.21.0(eslint@9.19.0(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/utils': 8.21.0(eslint@9.19.0(jiti@2.6.1))(typescript@5.9.3) - eslint: 9.19.0(jiti@2.6.1) - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - - typescript@5.9.3: {} - - undici-types@7.19.2: {} - - update-browserslist-db@1.2.3(browserslist@4.28.2): - dependencies: - browserslist: 4.28.2 - escalade: 3.2.0 - picocolors: 1.1.1 - - uri-js@4.4.1: - dependencies: - punycode: 2.3.1 - - uuid@8.3.2: {} - - watchpack@2.5.1: - dependencies: - glob-to-regexp: 0.4.1 - graceful-fs: 4.2.11 - - webidl-conversions@4.0.2: {} - - webp-converter@2.3.3: - dependencies: - uuid: 8.3.2 - - webpack-sources@3.4.1: {} - - webpack@5.105.0(esbuild@0.25.0): - dependencies: - '@types/eslint-scope': 3.7.7 - '@types/estree': 1.0.8 - '@types/json-schema': 7.0.15 - '@webassemblyjs/ast': 1.14.1 - '@webassemblyjs/wasm-edit': 1.14.1 - '@webassemblyjs/wasm-parser': 1.14.1 - acorn: 8.16.0 - acorn-import-phases: 1.0.4(acorn@8.16.0) - browserslist: 4.28.2 - chrome-trace-event: 1.0.4 - enhanced-resolve: 5.21.0 - es-module-lexer: 2.1.0 - eslint-scope: 5.1.1 - events: 3.3.0 - glob-to-regexp: 0.4.1 - graceful-fs: 4.2.11 - json-parse-even-better-errors: 2.3.1 - loader-runner: 4.3.2 - mime-types: 2.1.35 - neo-async: 2.6.2 - schema-utils: 4.3.3 - tapable: 2.3.3 - terser-webpack-plugin: 5.5.0(esbuild@0.25.0)(webpack@5.105.0) - watchpack: 2.5.1 - webpack-sources: 3.4.1 - transitivePeerDependencies: - - '@swc/core' - - esbuild - - uglify-js - - whatwg-url@7.1.0: - dependencies: - lodash.sortby: 4.7.0 - tr46: 1.0.1 - webidl-conversions: 4.0.2 - - which@2.0.2: - dependencies: - isexe: 2.0.0 - - word-wrap@1.2.5: {} - - wrappy@1.0.2: {} - - ws@8.17.1: {} - - yallist@4.0.0: {} - - yauzl@2.10.0: - dependencies: - buffer-crc32: 0.2.13 - fd-slicer: 1.1.0 - - yocto-queue@0.1.0: {} - - zod@4.3.6: {} diff --git a/remotion/public/Boobateaholding.svg b/remotion/public/Boobateaholding.svg deleted file mode 100644 index 4beb4c352..000000000 --- a/remotion/public/Boobateaholding.svg +++ /dev/null @@ -1,223 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/remotion/public/Crying.svg b/remotion/public/Crying.svg deleted file mode 100644 index 0ca3ba84d..000000000 --- a/remotion/public/Crying.svg +++ /dev/null @@ -1,138 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/remotion/public/Cupholding.svg b/remotion/public/Cupholding.svg deleted file mode 100644 index 65ec1eea5..000000000 --- a/remotion/public/Cupholding.svg +++ /dev/null @@ -1,187 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/remotion/public/Laughing.svg b/remotion/public/Laughing.svg deleted file mode 100644 index dfd9bacf4..000000000 --- a/remotion/public/Laughing.svg +++ /dev/null @@ -1,138 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/remotion/public/bigsmilewithblackcap.svg b/remotion/public/bigsmilewithblackcap.svg deleted file mode 100644 index 0a232a2e8..000000000 --- a/remotion/public/bigsmilewithblackcap.svg +++ /dev/null @@ -1,165 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/remotion/public/celebrate.svg b/remotion/public/celebrate.svg deleted file mode 100644 index 47824882c..000000000 --- a/remotion/public/celebrate.svg +++ /dev/null @@ -1,204 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/remotion/public/hatwithbag.svg b/remotion/public/hatwithbag.svg deleted file mode 100644 index b08f9d4ad..000000000 --- a/remotion/public/hatwithbag.svg +++ /dev/null @@ -1,216 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/remotion/public/syicsmile.svg b/remotion/public/syicsmile.svg deleted file mode 100644 index d74060d15..000000000 --- a/remotion/public/syicsmile.svg +++ /dev/null @@ -1,164 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/remotion/public/wink.svg b/remotion/public/wink.svg deleted file mode 100644 index 07006a8ce..000000000 --- a/remotion/public/wink.svg +++ /dev/null @@ -1,154 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/remotion/remotion.config.ts b/remotion/remotion.config.ts deleted file mode 100644 index 636ae8986..000000000 --- a/remotion/remotion.config.ts +++ /dev/null @@ -1,11 +0,0 @@ -// See all configuration options: https://remotion.dev/docs/config -// Each option also is available as a CLI flag: https://remotion.dev/docs/cli - -// Note: When using the Node.JS APIs, the config file doesn't apply. Instead, pass options directly to the APIs - -import { Config } from "@remotion/cli/config"; -import { enableTailwind } from '@remotion/tailwind-v4'; - -Config.setVideoImageFormat("png"); -Config.setOverwriteOutput(true); -Config.overrideWebpackConfig(enableTailwind); diff --git a/remotion/scripts/render-runtime-assets.mjs b/remotion/scripts/render-runtime-assets.mjs deleted file mode 100644 index 820f635e8..000000000 --- a/remotion/scripts/render-runtime-assets.mjs +++ /dev/null @@ -1,265 +0,0 @@ -#!/usr/bin/env node - -import { execFile, execFileSync } from 'node:child_process'; -import { promisify } from 'node:util'; - -const execFileAsync = promisify(execFile); -import { chmodSync, existsSync, mkdirSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; -import { createRequire } from 'node:module'; -import os from 'node:os'; -import { dirname, join, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const require = createRequire(import.meta.url); -const __dirname = dirname(fileURLToPath(import.meta.url)); -const remotionRoot = resolve(__dirname, '..'); -const outputRoot = resolve(remotionRoot, '..', 'app', 'public', 'generated', 'remotion'); -const tempRoot = resolve(remotionRoot, 'out', 'runtime-assets'); -const remotionBinary = join(remotionRoot, 'node_modules', '.bin', 'remotion'); -const outputFps = 30; -const webpPackageRoot = dirname(require.resolve('webp-converter/package.json')); - -function resolveWebpBinary(name) { - switch (os.platform()) { - case 'darwin': - return join(webpPackageRoot, 'bin', 'libwebp_osx', 'bin', name); - case 'linux': - return join(webpPackageRoot, 'bin', 'libwebp_linux', 'bin', name); - case 'win32': - return join(webpPackageRoot, 'bin', 'libwebp_win64', 'bin', `${name}.exe`); - default: - throw new Error(`[remotion-runtime-assets] unsupported platform for vendored webp tools: ${os.platform()}`); - } -} - -const cwebpBinary = resolveWebpBinary('cwebp'); -const webpmuxBinary = resolveWebpBinary('webpmux'); - -const ALL_COLORS = ['yellow', 'burgundy', 'black', 'navy', 'green']; - -function resolveColorSet() { - const raw = (process.env.MASCOT_COLORS ?? '').trim(); - if (!raw) return ['yellow']; - if (raw.toLowerCase() === 'all') return ALL_COLORS; - const requested = raw - .split(',') - .map(s => s.trim()) - .filter(Boolean); - const unknown = requested.filter(c => !ALL_COLORS.includes(c)); - if (unknown.length > 0) { - throw new Error( - `[remotion-runtime-assets] unknown mascot color(s): ${unknown.join(', ')}. Allowed: ${ALL_COLORS.join(', ')}, or "all".` - ); - } - if (!requested.includes('yellow')) { - requested.unshift('yellow'); - } - return requested; -} - -const colors = resolveColorSet(); -console.log(`[remotion-runtime-assets] rendering colors: ${colors.join(', ')}`); -const baseVariants = [ - { composition: 'mascot-yellow-idle', profile: 'default', props: {} }, - { composition: 'mascot-yellow-talking', profile: 'default', props: {} }, - { composition: 'mascot-yellow-thinking', profile: 'default', props: {} }, - { - composition: 'mascot-yellow-idle', - profile: 'compact', - props: { groundShadowOpacity: 0.75, compactArmShading: true }, - }, - { - composition: 'mascot-yellow-talking', - profile: 'compact', - props: { groundShadowOpacity: 0.75, compactArmShading: true }, - }, - { - composition: 'mascot-yellow-thinking', - profile: 'compact', - props: { groundShadowOpacity: 0.75, compactArmShading: true }, - }, -]; -const variants = baseVariants.flatMap(variant => - colors.map(color => ({ - ...variant, - color, - props: { ...variant.props, mascotColor: color }, - })) -); - -function run(command, args, cwd) { - try { - execFileSync(command, args, { - cwd, - stdio: 'inherit', - env: process.env, - }); - } catch (error) { - if (error?.code === 'ENOENT') { - throw new Error( - `[remotion-runtime-assets] missing required executable "${command}". Install it and ensure it is on PATH.` - ); - } - throw error; - } -} - -function ensureCleanDir(dir) { - rmSync(dir, { recursive: true, force: true }); - mkdirSync(dir, { recursive: true }); -} - -function ensureExecutable(path) { - if (os.platform() !== 'win32') { - chmodSync(path, 0o755); - } -} - -function renderMov(composition, destination, props) { - if (!existsSync(remotionBinary)) { - throw new Error(`remotion CLI missing at ${remotionBinary}; run pnpm install in remotion/ first`); - } - - const args = [ - 'render', - composition, - destination, - '--codec=prores', - '--prores-profile=4444', - '--pixel-format=yuva444p10le', - ]; - if (Object.keys(props).length > 0) { - args.push('--props', JSON.stringify(props)); - } - run(remotionBinary, args, remotionRoot); -} - -function extractPngFrames(inputMov, frameDir) { - mkdirSync(frameDir, { recursive: true }); - run( - 'ffmpeg', - [ - '-y', - '-i', - inputMov, - '-an', - '-vsync', - 'passthrough', - '-pix_fmt', - 'rgba', - '-start_number', - '0', - join(frameDir, 'frame-%04d.png'), - ], - remotionRoot - ); -} - -function listFrames(frameDir, extension) { - return readdirSync(frameDir) - .filter(entry => entry.endsWith(extension)) - .sort() - .map(entry => join(frameDir, entry)); -} - -async function convertPngFramesToWebp(frameDir) { - const pngFrames = listFrames(frameDir, '.png'); - const webpFrames = new Array(pngFrames.length); - const concurrency = Math.max(1, Math.min(os.cpus()?.length ?? 4, 8)); - let nextIndex = 0; - let completed = 0; - const total = pngFrames.length; - - async function worker() { - while (true) { - const idx = nextIndex++; - if (idx >= total) return; - const pngFrame = pngFrames[idx]; - const webpFrame = pngFrame.replace(/\.png$/u, '.webp'); - try { - await execFileAsync( - cwebpBinary, - ['-quiet', '-q', '82', '-m', '4', '-alpha_q', '100', pngFrame, '-o', webpFrame], - { cwd: remotionRoot } - ); - } catch (error) { - if (error?.code === 'ENOENT') { - throw new Error( - `[remotion-runtime-assets] missing required executable "${cwebpBinary}". Install it and ensure it is on PATH.` - ); - } - throw error; - } - webpFrames[idx] = webpFrame; - completed += 1; - if (completed === total || completed % 30 === 0) { - console.log(`[remotion-runtime-assets] cwebp ${completed}/${total} frames`); - } - } - } - - await Promise.all(Array.from({ length: concurrency }, () => worker())); - return webpFrames; -} - -async function transcodeAnimatedWebp(inputMov, outputWebp, frameDir) { - extractPngFrames(inputMov, frameDir); - const webpFrames = await convertPngFramesToWebp(frameDir); - const frameDurationMs = String(Math.round(1000 / outputFps)); - const args = ['-loop', '0', '-bgcolor', '0,0,0,0']; - - // webpmux frame options: +duration+xoff+yoff+dispose+blend - // dispose=1 → clear canvas to background (transparent) before drawing the - // next frame. Without this, frames composite over previous ones and - // transparent mascot poses ghost on top of each other. - // -b → no blending; the frame's RGBA replaces the canvas pixels. With - // blending the alpha of the prior frame leaks through even after a - // dispose, producing a faint overlay around the silhouette. - for (const framePath of webpFrames) { - args.push('-frame', framePath, `+${frameDurationMs}+0+0+1-b`); - } - - args.push('-o', outputWebp); - run(webpmuxBinary, args, remotionRoot); -} - -ensureCleanDir(outputRoot); -ensureCleanDir(tempRoot); -ensureExecutable(cwebpBinary); -ensureExecutable(webpmuxBinary); - -const manifest = { - generatedAt: new Date().toISOString(), - format: 'image/webp', - variants: [], -}; - -for (const variant of variants) { - const profileDir = join(outputRoot, variant.profile, variant.color); - const tempVariantDir = join(tempRoot, variant.profile, variant.color, variant.composition); - mkdirSync(profileDir, { recursive: true }); - mkdirSync(tempVariantDir, { recursive: true }); - - const movPath = join(tempVariantDir, `${variant.composition}.mov`); - const webpPath = join(profileDir, `${variant.composition}.webp`); - const frameDir = join(tempVariantDir, 'frames'); - - console.log( - `[remotion-runtime-assets] rendering ${variant.profile}/${variant.color}/${variant.composition}` - ); - renderMov(variant.composition, movPath, variant.props); - await transcodeAnimatedWebp(movPath, webpPath, frameDir); - - manifest.variants.push({ - color: variant.color, - composition: variant.composition, - profile: variant.profile, - path: `${variant.profile}/${variant.color}/${variant.composition}.webp`, - props: variant.props, - }); -} - -writeFileSync(join(outputRoot, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`); -rmSync(tempRoot, { recursive: true, force: true }); - -console.log(`[remotion-runtime-assets] wrote assets to ${outputRoot}`); diff --git a/remotion/scripts/render-transparent.sh b/remotion/scripts/render-transparent.sh deleted file mode 100755 index ced87c30c..000000000 --- a/remotion/scripts/render-transparent.sh +++ /dev/null @@ -1,29 +0,0 @@ -#!/usr/bin/env bash -# Render one or more mascot compositions as transparent ProRes 4444 .mov files. -# -# Usage: -# ./scripts/render-transparent.sh # renders mascot-yellow-wave by default -# ./scripts/render-transparent.sh mascot-yellow-talking # renders one composition -# ./scripts/render-transparent.sh mascot-yellow-wave mascot-black-wave # renders multiple -# pnpm render:all # renders every variant -# -# Output: out/.mov -set -euo pipefail - -cd "$(dirname "$0")/.." -mkdir -p out - -COMPS=("$@") -if [ ${#COMPS[@]} -eq 0 ]; then - COMPS=("mascot-yellow-wave") -fi - -for comp in "${COMPS[@]}"; do - echo "▶ Rendering $comp → out/$comp.mov" - pnpm exec remotion render "$comp" "out/$comp.mov" \ - --codec=prores \ - --prores-profile=4444 \ - --pixel-format=yuva444p10le -done - -echo "✓ Done. Files in ./out/" diff --git a/remotion/src/Mascot/YellowMascotBumDance.tsx b/remotion/src/Mascot/YellowMascotBumDance.tsx deleted file mode 100644 index 2c3065c03..000000000 --- a/remotion/src/Mascot/YellowMascotBumDance.tsx +++ /dev/null @@ -1,419 +0,0 @@ -import React from "react"; -import { - AbsoluteFill, - Easing, - interpolate, - useCurrentFrame, - useVideoConfig, -} from "remotion"; - -/** - * YellowMascotBumDance - * - * Sequence: - * 0–59 : Front idle (gentle bob + head drift) - * 60–89 : Turn-around transition — scaleX squishes front to 0 - * 90–119 : Turn-around transition — scaleX expands back from 0 - * 120–269: Back view dancing — energetic bob, side sway, bum jiggle - */ -export const YellowMascotBumDance: React.FC = () => { - const frame = useCurrentFrame(); - const { fps, width, height } = useVideoConfig(); - - const size = Math.min(width, height) * 0.85; - - // ── Turn-around scaleX ───────────────────────────────────────────────────── - // 1 → 0 over frames 60-90, then 0 → 1 over frames 90-120 - const easeIO = Easing.inOut(Easing.cubic); - const turnScale = interpolate( - frame, - [60, 68, 76], - [1, 0, 1], - { extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: easeIO } - ); - - const showBack = frame >= 68; - - // ── Body bob ─────────────────────────────────────────────────────────────── - const bobFreq = showBack ? 2.8 : 1.2; - const bobAmp = showBack ? 22 : 12; - const bob = Math.sin((frame / fps) * Math.PI * bobFreq) * bobAmp; - - // ── Side sway (back dancing only) ────────────────────────────────────────── - const sway = showBack - ? Math.sin((frame / fps) * Math.PI * 1.4) * 18 - : 0; - - // ── Front: head drift + squash ───────────────────────────────────────────── - const dotPhase = (frame / fps) * Math.PI; - const headDx = Math.sin(dotPhase * 0.7) * 6; - const headDy = Math.sin(dotPhase) * 9; - const press = Math.max(0, Math.sin(dotPhase)); - const headSqY = 1 - 0.08 * press; - const headSqX = 1 + 0.05 * press; - - // ── Front: arm sway ──────────────────────────────────────────────────────── - const leftSway = Math.sin((frame / fps) * Math.PI * 1.6) * 7; - const rightSway = Math.sin((frame / fps) * Math.PI * 1.3 + 0.5) * 5; - - // ── Back: bum jiggle (two cheeks, opposite phase) ────────────────────────── - // Right cheek centre ≈ (628, 769) after the matrix transform - // Left cheek centre ≈ (336, 761) - const bumPhase = ((frame - 120) / fps) * Math.PI * 5.0; - const rBumDy = Math.sin(bumPhase) * 16; - const rBumDx = Math.sin(bumPhase * 0.6) * 9; - const rBumRot = Math.sin(bumPhase) * 6; - const lBumDy = Math.sin(bumPhase + Math.PI) * 16; - const lBumDx = Math.sin(bumPhase * 0.6 + Math.PI) * 9; - const lBumRot = Math.sin(bumPhase + Math.PI) * 6; - - // ── Back: dancing arms - // Left arm: positive (clockwise) base keeps arm swinging outward-left and visible - // Right arm: negative (counter-clockwise) base keeps arm swinging outward-right and visible - const danceArmL = Math.sin((frame / fps) * Math.PI * 2.4) * 22 + 20; - const danceArmR = Math.sin((frame / fps) * Math.PI * 2.4 + Math.PI) * 22 - 20; - - return ( - - - - - - - - - {/* Body */} - - - - - - - - - - - - - - - - - - {/* Head */} - - - - - - - - - - - - - - - - - - {/* Neck shadows */} - - - - - - - - - - - - {/* Front: left arm */} - - - - - - - - - - - - - - - - - - {/* Front: right arm */} - - - - - - - - - - - - - - - - - - {/* Back: right bum cheek */} - - - - - - - - - - - - - - - - {/* Back: left bum cheek */} - - - - - - - - - - - - - - - - {/* Eye highlight filters */} - - - - - - - - - - - - - - - - - - - - - - - - - - - {/* Cheek highlight filters */} - - - - - - - - - - - - - {/* Ground shadow */} - - - {/* Turn-around wrapper — scales around centre (500, 500) */} - - - {/* Bob + sway wrapper */} - - - {/* ════════════ FRONT VIEW (frames 0–89) ════════════ */} - {!showBack && ( - <> - {/* Head */} - - - - - {/* Body */} - - - - {/* Left arm — gentle idle sway */} - - - - - {/* Right arm — gentle idle sway */} - - - - - {/* Neck shadows */} - - - - - - - - {/* Left eye */} - - - - - - - - - {/* Right eye */} - - - - - - - - - - - - {/* Left cheek */} - - - - - - {/* Right cheek */} - - - - - - {/* Mouth */} - - - - )} - - {/* ════════════ BACK VIEW (frames 90+) ════════════ */} - {showBack && ( - <> - {/* Back arms drawn FIRST so body overlaps shoulder — looks back-attached */} - {/* Filters omitted: fixed filterUnits="userSpaceOnUse" bounds would clip rotated arms */} - {/* Left arm — clockwise base keeps arm swinging to the left, visible outside body */} - - - - - {/* Right arm — counter-clockwise base keeps arm swinging to the right, visible outside body */} - - - - - {/* Head (back — plain circle, no face) */} - - - {/* Body drawn on top of arms — covers shoulder join, arms look back-facing */} - - - - {/* Neck shadows */} - - - - - - - - {/* Right bum cheek — jiggle around centre ≈ (628, 769) */} - - - - - - - {/* Left bum cheek — jiggle (opposite phase) around centre ≈ (336, 761) */} - - - - - - - )} - - - {/* end bob+sway */} - - - {/* end turn wrapper */} - - - - ); -}; diff --git a/remotion/src/Mascot/lib/MascotCharacter.tsx b/remotion/src/Mascot/lib/MascotCharacter.tsx deleted file mode 100644 index 77e35717d..000000000 --- a/remotion/src/Mascot/lib/MascotCharacter.tsx +++ /dev/null @@ -1,586 +0,0 @@ -import React from "react"; -import { AbsoluteFill, Easing, interpolate, useCurrentFrame, useVideoConfig } from "remotion"; -import { z } from "zod"; -import { getMascotPalette, type MascotColor } from "./mascotPalette"; - -export const mascotSchema = z.object({ - arm: z.enum(["wave", "none", "steady"]).default("wave"), - face: z.enum(["normal"]).default("normal"), - talking: z.boolean().default(false), - sleeping: z.boolean().default(false), - thinking: z.boolean().default(false), - greeting: z.boolean().default(false), - mascotColor: z.enum(["yellow", "burgundy", "black", "navy", "green"]).default("yellow"), -}); - -export type MascotProps = z.infer; - -/** - * Mascot character — drives the custom yellow mascot SVG with the shared - * Remotion animation system: body bob, head-dot drift/squash, arm wave, blink. - * - * Use distinct `idPrefix` values if two instances appear in the same SVG tree - * so filter/gradient IDs don't collide. - */ -type ThinkingTiming = { - /** Seconds at which the idle→thinking ramp begins. Default 1.0. */ - thinkInStartSec?: number; - /** Seconds at which the idle→thinking ramp completes. Default 2.0. */ - thinkInEndSec?: number; - /** Seconds at which the thinking→idle ramp begins. If unset, the pose holds. */ - thinkOutStartSec?: number; - /** Seconds at which the thinking→idle ramp completes. Required if thinkOutStartSec is set. */ - thinkOutEndSec?: number; - /** Seconds at which the awake→sleep ramp begins. Default 2.5. */ - sleepStartSec?: number; - /** Seconds at which the awake→sleep ramp completes. Default 4.0. */ - sleepFullSec?: number; -}; - -export const MascotCharacter: React.FC = ({ - arm = "wave", - face = "normal", - talking = false, - sleeping = false, - thinking = false, - greeting = false, - mascotColor = "yellow", - idPrefix = "mascot", - thinkInStartSec = 1.0, - thinkInEndSec = 2.0, - thinkOutStartSec, - thinkOutEndSec, - sleepStartSec = 2.5, - sleepFullSec = 4.0, -}) => { - const palette = getMascotPalette(mascotColor as MascotColor); - const frame = useCurrentFrame(); - const { fps, width, height } = useVideoConfig(); - - // Gentle bob for the whole character. - const bob = Math.sin((frame / fps) * Math.PI * 1.2) * 14; - - // Head dot drifts independently and squashes when pressing into the body. - const dotPhase = (frame / fps) * Math.PI * 1.0; - const dotDx = Math.sin(dotPhase * 0.7) * 6; - const dotDy = Math.sin(dotPhase) * 9; - const press = Math.max(0, Math.sin(dotPhase)); - const dotSquashY = 1 - 0.08 * press; - const dotSquashX = 1 + 0.05 * press; - - // Right arm wave — keyframe-based hi-wave: 3 swings then a rest pause, loops every 2.4s. - // Negative rotation = arm tips upward (counterclockwise). Eased for natural feel. - const easeInOut = Easing.inOut(Easing.cubic); - const wavePeriod = Math.round(fps * 2.4); - const frameInCycle = frame % wavePeriod; - const wave = arm === "wave" - ? interpolate( - frameInCycle, - [0, wavePeriod * 0.12, wavePeriod * 0.25, wavePeriod * 0.38, wavePeriod * 0.50, wavePeriod * 0.62, wavePeriod * 0.75, wavePeriod], - [0, -9, 0, -7, 0, -5, 0, 0], - { extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: easeInOut }, - ) - : 0; - - // Left arm gentle sway — slower frequency, smaller amplitude. - const leftSway = Math.sin((frame / fps) * Math.PI * 1.6) * 7; - - // Steady right arm sway — mirrors left arm with slight phase offset. - const steadySway = Math.sin((frame / fps) * Math.PI * 1.6 + 0.3) * 6; - - // Lip sync — slowed to ~1.5–2.3 Hz for natural speech pace (was 2.25–3.55 Hz). - // Phase offset keeps them from closing simultaneously. - const talkA = Math.abs(Math.sin((frame / fps) * Math.PI * 3.0)); - const talkB = Math.abs(Math.sin((frame / fps) * Math.PI * 4.6 + 1.2)); - const mouthOpen = talking ? Math.max(talkA, talkB * 0.8) : 0; - // Tongue fades in only when mouth is open enough — prevents visible tongue during near-closed frames. - const tongueOpacity = talking ? Math.min(1, Math.max(0, (mouthOpen - 0.15) / 0.35)) : 0; - - // Blink every ~2.6s for ~6 frames. - const blinkPeriod = Math.round(fps * 2.6); - const blinkOffset = Math.round(blinkPeriod / 2); - const inBlink = (frame + blinkOffset) % blinkPeriod < 6; - const blinkScale = inBlink ? 0.12 : 1; - - // Sleep animation — slow eye-close then floating Zzz. - const sleepStartFrame = sleeping ? Math.round(fps * sleepStartSec) : 99999; - const sleepFullFrame = sleeping ? Math.round(fps * sleepFullSec) : 99999; - const inSleepTransition = sleeping && frame >= sleepStartFrame; - const sleepProgress = sleeping - ? sleepFullFrame <= sleepStartFrame - ? 1 - : interpolate(frame, [sleepStartFrame, sleepFullFrame], [0, 1], { - extrapolateLeft: "clamp", - extrapolateRight: "clamp", - easing: Easing.inOut(Easing.cubic), - }) - : 0; - const isAsleep = sleeping && (sleepFullFrame <= sleepStartFrame || frame >= sleepFullFrame); - - // Eye openness: normal blink while awake, slow droop during sleep transition. - const eyeScale = inSleepTransition ? Math.max(0, 1 - sleepProgress) : blinkScale; - // Suppress blink highlights mid-droop so pupils don't pop on/off. - const effectiveInBlink = inSleepTransition ? false : inBlink; - // Switch to sleep-arc eyes once eyelids have closed. - const showSleepEyes = sleeping && eyeScale <= 0.06; - - // Floating Z letters — staggered, drift up and fade out. - const zPeriod = Math.round(fps * 2.2); - const zBaseStart = sleepFullFrame + Math.round(fps * 0.4); - const getZ = (delay: number, baseX: number, fontSize: number) => { - const startAt = zBaseStart + delay; - if (!isAsleep || frame < startAt) return { x: baseX, y: 220 as number, opacity: 0 as number, fontSize }; - const cycleFrame = (frame - startAt) % zPeriod; - const t = cycleFrame / zPeriod; - return { - x: baseX + t * 20, - y: 220 - t * 120, - opacity: interpolate(t, [0, 0.1, 0.72, 1], [0, 1, 0.85, 0]), - fontSize, - }; - }; - // Thinking animation — arm raises, head tilts, eyes shift up, mouth changes. - // Ramp up from `thinkInStartSec` → `thinkInEndSec`. If thinkOutStartSec/EndSec - // are provided, ramp back down so the pose returns to idle (loop-friendly). - const thinkStartFrame = thinking ? Math.round(fps * thinkInStartSec) : 99999; - const thinkFullFrame = thinking ? Math.round(fps * thinkInEndSec) : 99999; - const hasOutRamp = thinking && thinkOutStartSec !== undefined && thinkOutEndSec !== undefined; - const thinkOutStartFrame = hasOutRamp ? Math.round(fps * (thinkOutStartSec as number)) : 99999; - const thinkOutEndFrame = hasOutRamp ? Math.round(fps * (thinkOutEndSec as number)) : 99999; - const thinkInProgress = thinking - ? thinkFullFrame <= thinkStartFrame - ? 1 - : interpolate(frame, [thinkStartFrame, thinkFullFrame], [0, 1], { - extrapolateLeft: "clamp", - extrapolateRight: "clamp", - easing: Easing.inOut(Easing.cubic), - }) - : 0; - const thinkOutProgress = hasOutRamp - ? interpolate(frame, [thinkOutStartFrame, thinkOutEndFrame], [0, 1], { - extrapolateLeft: "clamp", - extrapolateRight: "clamp", - easing: Easing.inOut(Easing.cubic), - }) - : 0; - const thinkProgress = Math.max(0, thinkInProgress - thinkOutProgress); - // "Fully in pose" — only true while held between in-ramp end and out-ramp start. - const isThinking = thinking && thinkInProgress >= 1 && thinkOutProgress <= 0; - - // LEFT arm raises toward body/chin for thinking pose (matches reference: arm on viewer's left side). - // Normal left arm droops at ~127° from +x axis; rotating −128° brings it to ~−1° - // (nearly horizontal, pointing right toward body center — "hand near chin" read). - const thinkArmOscillate = isThinking ? Math.sin((frame / fps) * Math.PI * 0.5) * 2 : 0; - const effectiveLeftSway = thinking - ? interpolate(thinkProgress, [0, 1], [leftSway, -128]) + thinkArmOscillate - : leftSway; - - // Right arm stays in normal steady position while thinking. - const rightSteadyAngle = steadySway; - - // Head tilts slightly toward raised arm (left = negative rotation in SVG). - const headTilt = isThinking - ? -4.5 + Math.sin((frame / fps) * Math.PI * 0.38) * 1.8 - : thinking - ? interpolate(thinkProgress, [0, 1], [0, -4.5]) - : 0; - - // Eyes drift up-left — looking toward the raised arm / into the distance. - const thinkEyeX = thinking ? thinkProgress * -6 : 0; - const thinkEyeY = thinking ? thinkProgress * -9 : 0; - - // Greeting — right arm rises from resting to raised, then waves "hi" in a loop. - const greetStartFrame = greeting ? Math.round(fps * 0.8) : 99999; - const greetRaiseEnd = greeting ? Math.round(fps * 1.6) : 99999; - const isGreeting = greeting && frame >= greetStartFrame; - const greetRaiseProgress = greeting - ? interpolate(frame, [greetStartFrame, greetRaiseEnd], [0, 1], { - extrapolateLeft: "clamp", - extrapolateRight: "clamp", - easing: Easing.out(Easing.cubic), - }) - : 0; - // Raise: wave arm rotates from +52° (arm pointing right/down) up to 0° (arm raised). - const greetRaiseAngle = interpolate(greetRaiseProgress, [0, 1], [52, 0]); - // Hi wave: enthusiastic oscillation after the arm is fully raised. - const greetWavePeriod = Math.round(fps * 1.3); - const greetWaveFrame = (greeting && frame > greetRaiseEnd) - ? (frame - greetRaiseEnd) % greetWavePeriod - : 0; - const greetWaveOscillate = (greeting && frame > greetRaiseEnd) - ? interpolate( - greetWaveFrame, - [0, greetWavePeriod * 0.25, greetWavePeriod * 0.5, greetWavePeriod * 0.75, greetWavePeriod], - [0, -28, -2, -26, 0], - { extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: Easing.inOut(Easing.cubic) }, - ) - : 0; - const greetArmAngle = greetRaiseAngle + greetWaveOscillate; - - const z1 = getZ(0, 605, 40); - const z2 = getZ(Math.round(fps * 0.72), 624, 56); - const z3 = getZ(Math.round(fps * 1.44), 643, 76); - - const size = Math.min(width, height) * 0.85; - const p = (k: string) => `${idPrefix}-${k}`; - - return ( - - - - {/* Ground shadow gradient */} - - - - - - {/* filter0: body — inner shadows + grain texture */} - - - - - - - - - - - - - - - - - - - - - {/* filter1: head circle — inner shadows + grain texture */} - - - - - - - - - - - - - - - - - - - - - {/* filter2: neck shadow 1 — blur */} - - - - - - - {/* filter3: neck shadow 2 — blur */} - - - - - - - {/* filter4: right arm — inner shadows + grain texture */} - - - - - - - - - - - - - - - - - - - - - {/* filter5: left arm — inner shadows + grain texture */} - - - - - - - - - - - - - - - - - - - - - {/* filter6-7: left eye highlights */} - - - - - - - - - - - - {/* filter8-10: right eye highlights */} - - - - - - - - - - - - - - - - - {/* filter13: steady right arm (idle pose) — mirrors left arm, inner shadows + grain */} - - - - - - - - - - - - - - - - - - - - - {/* filter11-12: cheek highlights */} - - - - - - - - - - - - - {/* Ground shadow — scales with bob so it feels grounded. */} - - - - - {/* Everything bobs together. */} - - - {/* Head dot — drifts + squashes independently inside the bob group. */} - - - - - {/* Body */} - - - {/* Waving right arm — normal wave OR greeting raise+hi-wave. */} - {(arm === "wave" || isGreeting) && ( - - - - )} - - {/* Steady right arm — hidden once greeting raise begins. */} - {arm === "steady" && !isGreeting && ( - - - - )} - - {/* Left arm — gentle sway in idle; rotates up toward body center while thinking. */} - - - - - {/* Neck shadow details */} - - - - - - - - {/* Normal face — eyes, cheeks, mouth. - Wrapped in a rotation group for the thinking head-tilt. */} - {face === "normal" && ( - - {/* Sleep eyes — curved closed-lid arcs, visible only when eyeScale ≈ 0 */} - {showSleepEyes && ( - <> - - - - )} - - {/* Left eye — scaleY collapses on blink/sleep; translate shifts gaze while thinking */} - {!showSleepEyes && ( - - - - {!effectiveInBlink && ( - <> - - - - - - - - )} - - - )} - - {/* Right eye — same blink / sleep; translate shifts gaze while thinking */} - {!showSleepEyes && ( - - - - {!effectiveInBlink && ( - <> - - - - - - - - - - - )} - - - )} - - {/* Left cheek */} - - - - - - {/* Right cheek */} - - - - - - {/* Mouth — normal smile fades to a concerned "hmm" when thinking */} - {!talking && ( - <> - {/* Normal closed smile — fades out as thinking kicks in */} - - - - - {/* Thinking / "hmm" mouth — asymmetric slight frown, fades in */} - {thinking && ( - - )} - - )} - - {/* Talking mouth — pivot at top edge (y=508). - Whole group scales downward so mouth opens like a jaw drop. - Tongue is sized to stay within mouth walls at all mouthOpen values: - at cx=495 cy=532 rx=24, the widest point (y=532) sits inside the - ~73px-wide mouth cavity, with ≥8px margin on each side. */} - {talking && ( - - {/* Outer mouth: wide rounded top, deep U-curve bottom */} - - {/* Tongue — centered, safely inside mouth at full open. - Fades in so it's invisible while mouth is nearly closed. */} - - {/* Specular highlight on tongue */} - - - )} - - )} - - {/* Zzz — floating letters that drift up after mascot falls asleep */} - {isAsleep && ( - <> - Z - Z - Z - - )} - - - - ); -}; diff --git a/remotion/src/Mascot/lib/index.ts b/remotion/src/Mascot/lib/index.ts deleted file mode 100644 index 5b24ab9f1..000000000 --- a/remotion/src/Mascot/lib/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { MascotCharacter, mascotSchema, type MascotProps } from "./MascotCharacter"; diff --git a/remotion/src/Mascot/lib/mascotPalette.ts b/remotion/src/Mascot/lib/mascotPalette.ts deleted file mode 100644 index ead40ccf4..000000000 --- a/remotion/src/Mascot/lib/mascotPalette.ts +++ /dev/null @@ -1,73 +0,0 @@ -export type MascotColor = 'yellow' | 'burgundy' | 'black' | 'navy' | 'green'; - -export interface MascotPalette { - armHighlightMatrix: string; - armShadowMatrix: string; - bodyFill: string; - bodyHighlightMatrix: string; - bodyShadowMatrix: string; - headHighlightMatrix: string; - headShadowMatrix: string; - neckShadowColor: string; -} - -const YELLOW_PALETTE: MascotPalette = { - armHighlightMatrix: '0 0 0 0 0.973501 0 0 0 0 0.909066 0 0 0 0 0.671677 0 0 0 1 0', - armShadowMatrix: '0 0 0 0 0.796078 0 0 0 0 0.576471 0 0 0 0 0.0980392 0 0 0 1 0', - bodyFill: '#F7D145', - bodyHighlightMatrix: '0 0 0 0 0.962384 0 0 0 0 0.860378 0 0 0 0 0.484572 0 0 0 1 0', - bodyShadowMatrix: '0 0 0 0 0.797063 0 0 0 0 0.575703 0 0 0 0 0.0980312 0 0 0 1 0', - headHighlightMatrix: '0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0', - headShadowMatrix: '0 0 0 0 0.797063 0 0 0 0 0.575703 0 0 0 0 0.0980312 0 0 0 1 0', - neckShadowColor: '#B23C05', -}; - -const BLACK_PALETTE: MascotPalette = { - armHighlightMatrix: '0 0 0 0 0.439216 0 0 0 0 0.439216 0 0 0 0 0.439216 0 0 0 1 0', - armShadowMatrix: '0 0 0 0 0.0235294 0 0 0 0 0.0196078 0 0 0 0 0.0156863 0 0 0 1 0', - bodyFill: '#3A3A3A', - bodyHighlightMatrix: '0 0 0 0 0.439078 0 0 0 0 0.439078 0 0 0 0 0.439078 0 0 0 1 0', - bodyShadowMatrix: '0 0 0 0 0.0229492 0 0 0 0 0.0207891 0 0 0 0 0.0161271 0 0 0 1 0', - headHighlightMatrix: '0 0 0 0 0.439216 0 0 0 0 0.439216 0 0 0 0 0.439216 0 0 0 1 0', - headShadowMatrix: '0 0 0 0 0.0235294 0 0 0 0 0.0196078 0 0 0 0 0.0156863 0 0 0 1 0', - neckShadowColor: '#030100', -}; - -const palettes: Record = { - yellow: YELLOW_PALETTE, - burgundy: { - armHighlightMatrix: '0 0 0 0 0.607843 0 0 0 0 0.235294 0 0 0 0 0.313726 0 0 0 1 0', - armShadowMatrix: '0 0 0 0 0.27451 0 0 0 0 0.0745098 0 0 0 0 0.129412 0 0 0 1 0', - bodyFill: '#8A2647', - bodyHighlightMatrix: '0 0 0 0 0.607843 0 0 0 0 0.235294 0 0 0 0 0.313726 0 0 0 1 0', - bodyShadowMatrix: '0 0 0 0 0.27451 0 0 0 0 0.0745098 0 0 0 0 0.129412 0 0 0 1 0', - headHighlightMatrix: '0 0 0 0 0.854902 0 0 0 0 0.611765 0 0 0 0 0.690196 0 0 0 1 0', - headShadowMatrix: '0 0 0 0 0.27451 0 0 0 0 0.0745098 0 0 0 0 0.129412 0 0 0 1 0', - neckShadowColor: '#541128', - }, - black: BLACK_PALETTE, - navy: { - armHighlightMatrix: '0 0 0 0 0.270588 0 0 0 0 0.447059 0 0 0 0 0.654902 0 0 0 1 0', - armShadowMatrix: '0 0 0 0 0.0705882 0 0 0 0 0.14902 0 0 0 0 0.270588 0 0 0 1 0', - bodyFill: '#234B74', - bodyHighlightMatrix: '0 0 0 0 0.270588 0 0 0 0 0.447059 0 0 0 0 0.654902 0 0 0 1 0', - bodyShadowMatrix: '0 0 0 0 0.0705882 0 0 0 0 0.14902 0 0 0 0 0.270588 0 0 0 1 0', - headHighlightMatrix: '0 0 0 0 0.603922 0 0 0 0 0.760784 0 0 0 0 0.905882 0 0 0 1 0', - headShadowMatrix: '0 0 0 0 0.0705882 0 0 0 0 0.14902 0 0 0 0 0.270588 0 0 0 1 0', - neckShadowColor: '#16324D', - }, - green: { - armHighlightMatrix: '0 0 0 0 0.403922 0 0 0 0 0.654902 0 0 0 0 0.364706 0 0 0 1 0', - armShadowMatrix: '0 0 0 0 0.113725 0 0 0 0 0.270588 0 0 0 0 0.117647 0 0 0 1 0', - bodyFill: '#5FA64F', - bodyHighlightMatrix: '0 0 0 0 0.403922 0 0 0 0 0.654902 0 0 0 0 0.364706 0 0 0 1 0', - bodyShadowMatrix: '0 0 0 0 0.113725 0 0 0 0 0.270588 0 0 0 0 0.117647 0 0 0 1 0', - headHighlightMatrix: '0 0 0 0 0.780392 0 0 0 0 0.894118 0 0 0 0 0.733333 0 0 0 1 0', - headShadowMatrix: '0 0 0 0 0.113725 0 0 0 0 0.270588 0 0 0 0 0.117647 0 0 0 1 0', - neckShadowColor: '#2E5A24', - }, -}; - -export function getMascotPalette(color: MascotColor): MascotPalette { - return palettes[color]; -} diff --git a/remotion/src/Mascot/mascot-black-celebrate.tsx b/remotion/src/Mascot/mascot-black-celebrate.tsx deleted file mode 100644 index 54a4a90f5..000000000 --- a/remotion/src/Mascot/mascot-black-celebrate.tsx +++ /dev/null @@ -1,355 +0,0 @@ -import React from "react"; -import { - AbsoluteFill, - interpolate, - useCurrentFrame, - useVideoConfig, -} from "remotion"; - -export const BlackMascotCelebrate: React.FC = () => { - const frame = useCurrentFrame(); - const { fps, width, height } = useVideoConfig(); - const p = (k: string) => `bmcl-${k}`; - - // ── Body bob — energetic 2 Hz bounce ──────────────────────────────────── - const bob = Math.sin((frame / fps) * Math.PI * 2.0) * 16; - - // ── Head drift + squash ────────────────────────────────────────────────── - const dotPhase = (frame / fps) * Math.PI; - const headDx = Math.sin(dotPhase * 0.7) * 6; - const headDy = Math.sin(dotPhase) * 9; - const press = Math.max(0, Math.sin(dotPhase)); - const headSquashY = 1 - 0.09 * press; - const headSquashX = 1 + 0.06 * press; - - // ── Hat wobble ─────────────────────────────────────────────────────────── - const hatWobble = Math.sin((frame / fps) * Math.PI * 1.4) * 3.5; - - // ── Left arm — enthusiastic wave ───────────────────────────────────────── - const leftArmAngle = - -50 + Math.sin((frame / fps) * Math.PI * 3.0) * 18; - - // ── Right arm + horn — wave together ───────────────────────────────────── - const hornWave = Math.sin((frame / fps) * Math.PI * 2.5 + 0.5) * 8; - - // ── Confetti sparkle pulses ─────────────────────────────────────────────── - const sp = (phase: number) => - 0.62 + Math.sin((frame / fps) * Math.PI * 1.8 + phase) * 0.32; - - // ── Falling confetti particles ──────────────────────────────────────────── - const fallingDefs = [ - { delay: 0, x: 130, drift: 22, color: "#3C5FAD", w: 12, h: 8 }, - { delay: 8, x: 255, drift: -17, color: "#FBD387", w: 9, h: 9 }, - { delay: 3, x: 375, drift: 25, color: "#B1D37E", w: 11, h: 7 }, - { delay: 14, x: 495, drift: -20, color: "#929ED3", w: 8, h: 10 }, - { delay: 1, x: 630, drift: 18, color: "#F5A29A", w: 10, h: 8 }, - { delay: 17, x: 750, drift: -26, color: "#EDB371", w: 9, h: 11 }, - { delay: 6, x: 850, drift: 15, color: "#B2ACD2", w: 12, h: 7 }, - { delay: 11, x: 190, drift: 30, color: "#FDB1AF", w: 8, h: 9 }, - { delay: 2, x: 695, drift: -12, color: "#3C5FAD", w: 11, h: 8 }, - { delay: 19, x: 440, drift: 23, color: "#FBD387", w: 9, h: 10 }, - ]; - const fallPeriod = Math.round(fps * 2.2); - const getFall = (delay: number, startX: number, driftX: number) => { - if (frame < delay) return { x: startX, y: -80, rotation: 0, opacity: 0 }; - const c = (frame - delay) % fallPeriod; - const t = c / fallPeriod; - return { - x: startX + driftX * t + Math.sin(t * Math.PI * 5) * 13, - y: -80 + 1100 * t, - rotation: t * 540, - opacity: interpolate(t, [0, 0.04, 0.82, 1], [0, 1, 0.88, 0], { - extrapolateLeft: "clamp", extrapolateRight: "clamp", - }), - }; - }; - - const size = Math.min(width, height) * 0.82; - - return ( - - - - - - - - - {/* Body — from blackcelebrate.svg filter0 */} - - - - - - - - - - - - - - - - - - {/* Head circle — from blackcelebrate.svg filter1 */} - - - - - - - - - - - - - - - - - - {/* Neck shadows — filter2, filter3 */} - - - - - - - - - - - - {/* Left arm — from blackcelebrate.svg filter4 */} - - - - - - - - - - - - - - - - - - {/* Cheek highlights — filter5, filter6 */} - - - - - - - - - - - - {/* Raised right arm — from blackcelebrate.svg filter7 */} - - - - - - - - - - - - - - - - - - - {/* Falling confetti rain */} - {fallingDefs.map((cd, i) => { - const ft = getFall(cd.delay, cd.x, cd.drift); - return ( - - ); - })} - - {/* Ground shadow */} - - - {/* Everything bobs together */} - - - {/* Cone hat cluster — tracks head drift, wobbles at base */} - - - - - - - - - - - - - - - - - - - - {/* Body */} - - - {/* Left arm — waves enthusiastically */} - - - - - {/* Cone horn in hand + raised right arm — wave together */} - - {/* Horn cone */} - - - - - {/* Sparkle circles from horn */} - - - - - - - - - - - - {/* Horn base / tube connecting to arm */} - - {/* Raised right arm */} - - - - {/* Scattered party confetti pieces */} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - {/* Head group: drift + squash */} - - - {/* Neck shadows */} - - - - - - - - {/* Head circle */} - - - {/* Happy ^^ eyes */} - - - - {/* Left cheek */} - - - - - - {/* Right cheek */} - - - - - - {/* Open mouth + tongue */} - - - - - {/* end head group */} - - - {/* end bob group */} - - - - ); -}; diff --git a/remotion/src/Mascot/mascot-black-crying.tsx b/remotion/src/Mascot/mascot-black-crying.tsx deleted file mode 100644 index 20b5aa246..000000000 --- a/remotion/src/Mascot/mascot-black-crying.tsx +++ /dev/null @@ -1,364 +0,0 @@ -import React from "react"; -import { - AbsoluteFill, - Easing, - interpolate, - useCurrentFrame, - useVideoConfig, -} from "remotion"; - -export const BlackMascotCrying: React.FC = () => { - const frame = useCurrentFrame(); - const { fps, width, height } = useVideoConfig(); - const p = (k: string) => `bmcry-${k}`; - - // ── Cry transition ───────────────────────────────────────────────────────── - const cryProgress = interpolate(frame, [60, 90], [0, 1], { - extrapolateLeft: "clamp", - extrapolateRight: "clamp", - easing: Easing.inOut(Easing.cubic), - }); - const normalFaceOpacity = 1 - cryProgress; - const cryFaceOpacity = cryProgress; - - // ── Body bob — calm idle blends into faster sob shudder ─────────────────── - const idleBob = Math.sin((frame / fps) * Math.PI * 1.2) * 14; - const sobBob = - Math.sin((frame / fps) * Math.PI * 2.8) * 10 + - Math.sin((frame / fps) * Math.PI * 5.5 + 0.7) * 3; - const bob = idleBob * (1 - cryProgress) + sobBob * cryProgress; - - // ── Head drift + squash ─────────────────────────────────────────────────── - const dotPhase = (frame / fps) * Math.PI; - const driftScale = 1 - cryProgress * 0.65; - const headDx = Math.sin(dotPhase * 0.7) * 6 * driftScale; - const headDy = Math.sin(dotPhase) * 9 * driftScale; - const press = Math.max(0, Math.sin(dotPhase)) * driftScale; - const headSquashY = 1 - 0.08 * press; - const headSquashX = 1 + 0.05 * press; - - // ── Arms — gentle idle sway, droop down when crying ────────────────────── - const leftSway = Math.sin((frame / fps) * Math.PI * 1.3) * 7; - const rightSway = Math.sin((frame / fps) * Math.PI * 1.3 + 1.0) * 6; - const leftArmAngle = leftSway + cryProgress * 14; - const rightArmAngle = rightSway + cryProgress * 14; - - // ── Blink — only during idle phase ─────────────────────────────────────── - const blinkPeriod = Math.round(fps * 2.6); - const blinkOffset = Math.round(blinkPeriod / 2); - const inBlink = cryProgress < 0.15 && (frame + blinkOffset) % blinkPeriod < 6; - const eyeScaleNormal = inBlink ? 0.12 : 1; - - // ── Cheeks — flush more as crying intensifies ───────────────────────────── - const cheekOpacity = - 0.82 + cryProgress * 0.16 + Math.sin((frame / fps) * Math.PI * 1.1) * 0.05; - - // ── Tears ───────────────────────────────────────────────────────────────── - const tearPeriod = Math.round(fps * 1.6); - const getTear = (delayFrames: number, eyeX: number, eyeStartY: number) => { - const startAt = 90 + delayFrames; - if (frame < startAt) return { x: eyeX, y: eyeStartY, opacity: 0 }; - const cycleFrame = (frame - startAt) % tearPeriod; - const t = cycleFrame / tearPeriod; - const y = eyeStartY + t * 170; - const opacity = - interpolate(t, [0, 0.07, 0.68, 1.0], [0, 0.9, 0.75, 0], { - extrapolateLeft: "clamp", - extrapolateRight: "clamp", - }) * cryProgress; - return { x: eyeX, y, opacity }; - }; - - const tL1 = getTear(0, 395, 485); - const tL2 = getTear(Math.round(fps * 0.55), 408, 485); - const tR1 = getTear(Math.round(fps * 0.22), 592, 485); - const tR2 = getTear(Math.round(fps * 0.80), 603, 485); - - const size = Math.min(width, height) * 0.82; - - return ( - - - - {/* Ground shadow */} - - - - - - {/* Body */} - - - - - - - - - - - - - - - - - - {/* Head circle */} - - - - - - - - - - - - - - - - - - {/* Neck shadows */} - - - - - - - - - - - - {/* Left arm */} - - - - - - - - - - - - - - - - - - {/* Right arm */} - - - - - - - - - - - - - - - - - - {/* Normal left eye highlights */} - - - - - - - - - - - - {/* Normal right eye highlights */} - - - - - - - - - - - - - - - - - {/* Cheek highlights */} - - - - - - - - - - - - - {/* Ground shadow */} - - - {/* Everything bobs together */} - - - {/* Body */} - - - {/* Left arm */} - - - - - {/* Right arm */} - - - - - {/* Tears */} - {[tL1, tL2, tR1, tR2].map((t, i) => ( - - - - ))} - - {/* Head group: drift + squash */} - - - {/* Neck shadows */} - - - - - - - - {/* Head circle */} - - - {/* Normal eyes */} - - {/* Left eye */} - - - - - - - - - - - {/* Right eye */} - - - - - - - - - - - - - - - {/* Crying eyes */} - - - - - - {/* Cheeks */} - - - - - - - - - - - - - - {/* Normal smile */} - - - - - - {/* Sad frown */} - - - - - - - {/* end head group */} - - - {/* end bob group */} - - - - ); -}; diff --git a/remotion/src/Mascot/mascot-black-hat-with-bag.tsx b/remotion/src/Mascot/mascot-black-hat-with-bag.tsx deleted file mode 100644 index 27f5b50e8..000000000 --- a/remotion/src/Mascot/mascot-black-hat-with-bag.tsx +++ /dev/null @@ -1,347 +0,0 @@ -import React from "react"; -import { - AbsoluteFill, - interpolate, - useCurrentFrame, - useVideoConfig, -} from "remotion"; - -export const BlackMascotHatWithBag: React.FC = () => { - const frame = useCurrentFrame(); - const { fps, width, height } = useVideoConfig(); - const p = (k: string) => `bmhb-${k}`; - - // ── Body bob ───────────────────────────────────────────────────────────── - const bob = Math.sin((frame / fps) * Math.PI * 1.2) * 10; - - // ── Head drift + squash ───────────────────────────────────────────────── - const dotPhase = (frame / fps) * Math.PI; - const headDx = Math.sin(dotPhase * 0.7) * 5; - const headDy = Math.sin(dotPhase) * 7; - const press = Math.max(0, Math.sin(dotPhase)); - const headSquashY = 1 - 0.08 * press; - const headSquashX = 1 + 0.05 * press; - - // ── Left arm — gentle idle sway ───────────────────────────────────────── - const leftArmAngle = Math.sin((frame / fps) * Math.PI * 0.8) * 8; - - // ── Right arm — opposite phase ────────────────────────────────────────── - const rightArmAngle = Math.sin((frame / fps) * Math.PI * 0.8 + Math.PI) * 8; - - // ── Bag pendulum ──────────────────────────────────────────────────────── - const bagSwing = Math.sin((frame / fps) * Math.PI * 1.0 + 0.45) * 3; - - // ── Blink ─────────────────────────────────────────────────────────────── - const blinkPeriod = Math.round(fps * 3.5); - const blinkDur = Math.round(fps * 0.13); - const blinkPhase = frame % blinkPeriod; - const eyeScaleY = - blinkPhase < blinkDur - ? interpolate( - blinkPhase, - [0, blinkDur * 0.35, blinkDur * 0.65, blinkDur], - [1, 0.06, 0.06, 1], - { extrapolateLeft: "clamp", extrapolateRight: "clamp" } - ) - : 1; - - const size = Math.min(width, height) * 0.82; - - return ( - - - - - - - - - {/* Body — from blackhatwithbag.svg filter0 */} - - - - - - - - - - - - - - - - - - {/* Head circle — from blackhatwithbag.svg filter1 */} - - - - - - - - - - - - - - - - - - {/* Neck shadows — filter2, filter3 */} - - - - - - - - - - - - {/* Left arm — from blackhatwithbag.svg filter4 */} - - - - - - - - - - - - - - - - - - {/* Left eye highlights — filter5, filter6 */} - - - - - - - - - - - - {/* Right eye highlights — filter7, filter8, filter9 */} - - - - - - - - - - - - - - - - - {/* Cheek highlights — filter10, filter11 */} - - - - - - - - - - - - {/* Right arm — from blackhatwithbag.svg filter12 */} - - - - - - - - - - - - - - - - - - {/* Hat shadow — filter13 */} - - - - - - - {/* Hat buckle details — filter14, filter15 */} - - - - - - - - - - - - - {/* Ground shadow */} - - - {/* Everything bobs */} - - - {/* Body */} - - - {/* Left arm */} - - - - - {/* Bag — gentle pendulum sway */} - - {/* Bag strap */} - - {/* Main bag body */} - - {/* Bag shadow */} - - {/* Bag clasp */} - - - - - - {/* Bag buckle dots */} - - - - - {/* Right arm */} - - - - - {/* Head group: drift (hat outside squash so it isn't distorted) */} - - - {/* Hat cluster — moves with head drift */} - - - - - - - - - - - - - - - {/* Head content: squash/stretch */} - - - {/* Neck shadows */} - - - - - - - - {/* Head circle */} - - - {/* Left eye */} - - - - - - - - - - - {/* Right eye */} - - - - - - - - - - - - - - {/* Left cheek */} - - - - - - {/* Right cheek */} - - - - - - {/* Smirk mouth */} - - - - - {/* end squash group */} - - {/* end head drift group */} - - - {/* end bob group */} - - - - ); -}; diff --git a/remotion/src/Mascot/mascot-black-idle.tsx b/remotion/src/Mascot/mascot-black-idle.tsx deleted file mode 100644 index ffcda42f2..000000000 --- a/remotion/src/Mascot/mascot-black-idle.tsx +++ /dev/null @@ -1,286 +0,0 @@ -import React from "react"; -import { AbsoluteFill, useCurrentFrame, useVideoConfig } from "remotion"; - -/** - * Black idle mascot — uses exact paths and filters from BlackIdelmascot.svg - * with the same bob, head-drift, arm-sway, and blink animations as the yellow idle. - */ -export const BlackMascotIdle: React.FC = () => { - const frame = useCurrentFrame(); - const { fps, width, height } = useVideoConfig(); - - // Gentle bob for the whole character. - const bob = Math.sin((frame / fps) * Math.PI * 1.2) * 14; - - // Head dot drifts independently and squashes when pressing into the body. - const dotPhase = (frame / fps) * Math.PI * 1.0; - const dotDx = Math.sin(dotPhase * 0.7) * 6; - const dotDy = Math.sin(dotPhase) * 9; - const press = Math.max(0, Math.sin(dotPhase)); - const dotSquashY = 1 - 0.08 * press; - const dotSquashX = 1 + 0.05 * press; - - // Left arm gentle sway. - const leftSway = Math.sin((frame / fps) * Math.PI * 1.6) * 7; - // Steady right arm sway — mirrors left arm with slight phase offset. - const steadySway = Math.sin((frame / fps) * Math.PI * 1.6 + 0.3) * 6; - - // Blink every ~2.6s for ~6 frames. - const blinkPeriod = Math.round(fps * 2.6); - const blinkOffset = Math.round(blinkPeriod / 2); - const inBlink = (frame + blinkOffset) % blinkPeriod < 6; - const eyeScale = inBlink ? 0.12 : 1; - - const size = Math.min(width, height) * 0.85; - - return ( - - - - {/* Ground shadow gradient */} - - - - - - {/* Body filter — from BlackIdelmascot.svg filter0_iig */} - - - - - - - - - - - - - - - - - - - - - {/* Head filter — from BlackIdelmascot.svg filter1_iig */} - - - - - - - - - - - - - - - - - - - - - {/* Neck shadow filters */} - - - - - - - - - - - - {/* Left arm filter — from BlackIdelmascot.svg filter4_iig */} - - - - - - - - - - - - - - - - - - - - - {/* Right steady arm filter — from BlackIdelmascot.svg filter5_iig */} - - - - - - - - - - - - - - - - - - - - - {/* Left eye highlight filters */} - - - - - - - - - - - - {/* Right eye highlight filters */} - - - - - - - - - - - - - - - - - {/* Cheek highlight filters */} - - - - - - - - - - - - - {/* Ground shadow */} - - - - - {/* Everything bobs together */} - - - {/* Head — drifts + squashes independently */} - - - - - - - {/* Body */} - - - - - {/* Right steady arm — gentle sway */} - - - - - - - {/* Left arm — gentle sway */} - - - - - - - {/* Neck shadows */} - - - - - - - - {/* Left eye — scaleY collapses on blink */} - - - {!inBlink && ( - <> - - - - - - - - )} - - - {/* Right eye — scaleY collapses on blink */} - - - {!inBlink && ( - <> - - - - - - - - - - - )} - - - {/* Left cheek */} - - - - - - {/* Right cheek */} - - - - - - {/* Mouth */} - - - - - - ); -}; diff --git a/remotion/src/Mascot/mascot-black-laughing.tsx b/remotion/src/Mascot/mascot-black-laughing.tsx deleted file mode 100644 index 3c5f568ec..000000000 --- a/remotion/src/Mascot/mascot-black-laughing.tsx +++ /dev/null @@ -1,236 +0,0 @@ -import React from "react"; -import { - AbsoluteFill, - useCurrentFrame, - useVideoConfig, -} from "remotion"; - -/** - * BlackMascotLaughing — black variant of YellowMascotLaughing. - * - * Both arms wave out-of-phase with laughter. - * Body bounces rapidly + shakes horizontally. - * Head tilts side-to-side. - * Happy ^^ eyes, open mouth + tongue. - * Filter matrices from BlackIdelmascot.svg. - */ -export const BlackMascotLaughing: React.FC = () => { - const frame = useCurrentFrame(); - const { fps, width, height } = useVideoConfig(); - const p = (k: string) => `bmla-${k}`; - - // ── Body bounce — rapid 3 Hz laughter bounce ──────────────────────────── - const bob = Math.sin((frame / fps) * Math.PI * 3.0) * 18; - - // ── Horizontal body wobble — small 5 Hz side shake ────────────────────── - const wobble = Math.sin((frame / fps) * Math.PI * 5.0) * 5; - - // ── Head drift + squash ───────────────────────────────────────────────── - const dotPhase = (frame / fps) * Math.PI; - const headDx = Math.sin(dotPhase * 2.5) * 8 + wobble * 0.5; - const headDy = Math.sin(dotPhase * 3.0) * 9; - const press = Math.max(0, Math.sin(dotPhase * 3.0)); - const headSquashY = 1 - 0.1 * press; - const headSquashX = 1 + 0.07 * press; - - // ── Head tilt — side-to-side laugh ────────────────────────────────────── - const headTilt = Math.sin((frame / fps) * Math.PI * 2.0) * 9; - - // ── Both arms shake with laughter (opposite phases) ───────────────────── - const leftArmAngle = -15 + Math.sin((frame / fps) * Math.PI * 3.5) * 25; - const rightArmAngle = 15 + Math.sin((frame / fps) * Math.PI * 3.5 + 1.1) * 25; - - const size = Math.min(width, height) * 0.82; - - return ( - - - - - - - - - {/* Body */} - - - - - - - - - - - - - - - - - - {/* Head circle */} - - - - - - - - - - - - - - - - - - {/* Neck shadows */} - - - - - - - - - - - - {/* Left arm */} - - - - - - - - - - - - - - - - - - {/* Right arm */} - - - - - - - - - - - - - - - - - - {/* Cheek highlights */} - - - - - - - - - - - - - {/* Ground shadow */} - - - {/* ── Everything bobs + wobbles ─────────────────────────────────────── */} - - - {/* ── Body ────────────────────────────────────────────────────────── */} - - - {/* ── Left arm — shakes up with laughter ─────────────────────────── */} - - - - - {/* ── Right arm — shakes up with laughter (opposite phase) ─────────── */} - - - - - {/* ── Head group: drift + tilt + squash ───────────────────────────── */} - - - - {/* Neck shadows */} - - - - - - - - {/* Head circle */} - - - {/* Happy ^^ eyes */} - - - - {/* Left cheek */} - - - - - - {/* Right cheek */} - - - - - - {/* Open mouth + tongue */} - - - - - - {/* end head group */} - - - {/* end bob group */} - - - - ); -}; diff --git a/remotion/src/Mascot/mascot-black-listening.tsx b/remotion/src/Mascot/mascot-black-listening.tsx deleted file mode 100644 index 8a5ae3ea3..000000000 --- a/remotion/src/Mascot/mascot-black-listening.tsx +++ /dev/null @@ -1,306 +0,0 @@ -import React from "react"; -import { AbsoluteFill, useCurrentFrame, useVideoConfig } from "remotion"; - -/** - * Black listening mascot — uses exact paths and filters from BlackIdelmascot.svg. - * Replicates `mascot-yellow-listening`: - * • Prominent head tilt toward the raised side (primary listening cue) - * • Right arm held outward with gentle continuous sway - * • Left arm gentle idle sway - * • Slow body bob (calm, attentive breathing) - * • Slow blink (focused/listening) - * • Cheek warmth pulse - */ -export const BlackMascotListening: React.FC = () => { - const frame = useCurrentFrame(); - const { fps, width, height } = useVideoConfig(); - const p = (k: string) => `bmli-${k}`; - - // Body bob — slow, calm breathing rhythm. - const bob = Math.sin((frame / fps) * Math.PI * 0.9) * 9; - - // Head tilt — prominent attentive listening tilt to the right. - const headTiltBase = 11; - const headTiltOscillate = Math.sin((frame / fps) * Math.PI * 0.35) * 3; - const headTilt = headTiltBase + headTiltOscillate; - - // Subtle head nod while tilted. - const headNodY = Math.sin((frame / fps) * Math.PI * 0.55) * 6; - const headNodX = Math.sin((frame / fps) * Math.PI * 0.28) * 3; - - // Left arm — gentle idle sway. - const leftSway = Math.sin((frame / fps) * Math.PI * 1.1) * 6; - - // Right arm — held outward, gentle continuous sway. - const armSway = Math.sin((frame / fps) * Math.PI * 0.75) * 5; - const rightArmAngle = -62 + armSway; - - // Blink — slower, focused (attentive listener). - const blinkPeriod = Math.round(fps * 3.5); - const blinkOffset = Math.round(blinkPeriod * 0.3); - const inBlink = (frame + blinkOffset) % blinkPeriod < 5; - const eyeScale = inBlink ? 0.1 : 1; - - // Cheek warmth pulse. - const cheekOpacity = 0.78 + Math.sin((frame / fps) * Math.PI * 0.85 + 0.6) * 0.09; - - const size = Math.min(width, height) * 0.82; - - // Head tilt pivot: bottom of head circle = neck joint. - const headPivotX = 493; - const headPivotY = 255; // cy(145) + r(110) - - return ( - - - - {/* Ground shadow */} - - - - - - {/* Body filter — from BlackIdelmascot.svg filter0_iig */} - - - - - - - - - - - - - - - - - - {/* Head filter — from BlackIdelmascot.svg filter1_iig */} - - - - - - - - - - - - - - - - - - {/* Neck shadow filters */} - - - - - - - - - - - - {/* Left arm filter — from BlackIdelmascot.svg filter4_iig */} - - - - - - - - - - - - - - - - - - {/* Right arm filter — from BlackIdelmascot.svg filter5_iig */} - - - - - - - - - - - - - - - - - - {/* Left eye highlight filters */} - - - - - - - - - - - - {/* Right eye highlight filters */} - - - - - - - - - - - - - - - - - {/* Cheek highlight filters */} - - - - - - - - - - - - - {/* Ground shadow */} - - - {/* Everything bobs together */} - - - {/* Body */} - - - {/* Left arm — gentle idle sway */} - - - - - {/* Right arm — held outward with gentle sway */} - - - - - {/* Head group — everything tilts together */} - - - {/* Neck shadows */} - - - - - - - - {/* Head circle */} - - - {/* Left eye */} - - - {!inBlink && ( - <> - - - - - - - - )} - - - {/* Right eye */} - - - {!inBlink && ( - <> - - - - - - - - - - - )} - - - {/* Left cheek */} - - - - - - - - {/* Right cheek */} - - - - - - - - {/* Mouth — closed attentive smile */} - - - - - {/* end head group */} - - - {/* end bob group */} - - - - ); -}; diff --git a/remotion/src/Mascot/mascot-black-love.tsx b/remotion/src/Mascot/mascot-black-love.tsx deleted file mode 100644 index 950bf3d21..000000000 --- a/remotion/src/Mascot/mascot-black-love.tsx +++ /dev/null @@ -1,370 +0,0 @@ -import React from "react"; -import { AbsoluteFill, Easing, interpolate, useCurrentFrame, useVideoConfig } from "remotion"; - -/** - * Black love mascot — uses exact paths and filters from BlackIdelmascot.svg. - * Replicates `mascot-yellow-love`: - * 0 – 89 : normal idle (bob, head drift, arm sway, blink) - * 90 – 120 : heart eyes fade IN - * 120 – 210: heart eyes pulse, cheeks flush, mini hearts float up - * 210 – 240: heart eyes fade OUT - * 240 – 270: normal idle again → clean loop - */ -export const BlackMascotLove: React.FC = () => { - const frame = useCurrentFrame(); - const { fps, width, height } = useVideoConfig(); - const p = (k: string) => `bmlv-${k}`; - - // Heart transition. - const heartProgress = interpolate( - frame, - [90, 120, 210, 240], - [0, 1, 1, 0], - { extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: Easing.inOut(Easing.cubic) } - ); - const normalEyeOpacity = 1 - heartProgress; - const heartEyeOpacity = heartProgress; - - // Heart pulse: 2 beats/s, amplitude grows with heartProgress. - const heartBeat = 1 + Math.sin((frame / fps) * Math.PI * 4) * 0.09 * heartProgress; - - // Body bob. - const bob = Math.sin((frame / fps) * Math.PI * 1.2) * 14; - - // Head drift + squash. - const dotPhase = (frame / fps) * Math.PI; - const headDx = Math.sin(dotPhase * 0.7) * 6; - const headDy = Math.sin(dotPhase) * 9; - const press = Math.max(0, Math.sin(dotPhase)); - const headSqY = 1 - 0.07 * press; - const headSqX = 1 + 0.04 * press; - - // Arms — gentle idle sway. - const leftSway = Math.sin((frame / fps) * Math.PI * 1.3) * 7; - const rightSway = Math.sin((frame / fps) * Math.PI * 1.3 + 1.0) * 6; - - // Blink — only during normal eye phase. - const blinkPeriod = Math.round(fps * 2.8); - const blinkOffset = Math.round(blinkPeriod / 2); - const inBlink = heartProgress < 0.1 && (frame + blinkOffset) % blinkPeriod < 5; - const eyeScaleNormal = inBlink ? 0.1 : 1; - - // Cheek — flushes more during heart phase. - const cheekOpacity = - 0.82 + heartProgress * 0.15 + - Math.sin((frame / fps) * Math.PI * 1.1 + 1.0) * 0.06; - - // Floating mini hearts. - const floatHeart = (startF: number, x: number, baseY: number, sz: number) => { - const prog = interpolate(frame, [startF, startF + 48], [0, 1], { - extrapolateLeft: "clamp", extrapolateRight: "clamp", - }); - const y = baseY - 72 * prog + bob; - const op = interpolate( - frame, - [startF, startF + 6, startF + 38, startF + 48], - [0, 0.9, 0.9, 0], - { extrapolateLeft: "clamp", extrapolateRight: "clamp" } - ) * heartProgress; - const sc = sz * interpolate(frame, [startF, startF + 8], [0.5, 1], { - extrapolateLeft: "clamp", extrapolateRight: "clamp", - }); - return { x, y, op, sc }; - }; - const hA = floatHeart(124, 415, 388, 0.9); - const hB = floatHeart(152, 568, 382, 0.8); - const hC = floatHeart(176, 490, 358, 1.0); - - const size = Math.min(width, height) * 0.82; - - // Heart-eye SVG → idelMascot coordinate shift (same as yellow). - const HX = 113.386; - const HY = 31; - - return ( - - - - {/* Ground shadow */} - - - - - - {/* Body filter — from BlackIdelmascot.svg filter0_iig */} - - - - - - - - - - - - - - - - - - {/* Head circle filter — from BlackIdelmascot.svg filter1_iig */} - - - - - - - - - - - - - - - - - - {/* Neck shadow filters */} - - - - - - - - - - - - {/* Left arm filter — from BlackIdelmascot.svg filter4_iig */} - - - - - - - - - - - - - - - - - - {/* Right arm filter — from BlackIdelmascot.svg filter5_iig */} - - - - - - - - - - - - - - - - - - {/* Normal left eye highlights */} - - - - - - - - - - - - {/* Normal right eye highlights */} - - - - - - - - - - - - - - - - - {/* Cheek highlight filters */} - - - - - - - - - - - - {/* Pink glow blur (behind heart eyes) */} - - - - - - {/* Ground shadow */} - - - {/* Floating mini hearts (bob-synced, gated by heartProgress) */} - {[hA, hB, hC].map((h, i) => ( - - - - ))} - - {/* Everything bobs together */} - - - {/* Body */} - - - {/* Left arm */} - - - - - {/* Right arm */} - - - - - {/* Head group: drift + squash */} - - - {/* Neck shadows */} - - - - - - - - {/* Head circle */} - - - {/* Normal round eyes (fade out as heart phase begins) */} - - {/* Left eye */} - - - - - - - - - - - {/* Right eye */} - - - - - - - - - - - - - - - {/* Heart eyes (fade in, pulse with love) */} - - {/* Soft pink glow behind the hearts */} - - - - {/* Left heart eye */} - - - - - - - - {/* Right heart eye */} - - - - - - - - {/* Cheeks (flush during heart phase) */} - - - - - - - - - - - - - - {/* Mouth — closed content smile */} - - - - - {/* end head group */} - - - {/* end bob group */} - - - - ); -}; diff --git a/remotion/src/Mascot/mascot-black-pickup.tsx b/remotion/src/Mascot/mascot-black-pickup.tsx deleted file mode 100644 index 0ce3a23db..000000000 --- a/remotion/src/Mascot/mascot-black-pickup.tsx +++ /dev/null @@ -1,310 +0,0 @@ -import React from "react"; -import { AbsoluteFill, interpolate, useCurrentFrame, useVideoConfig } from "remotion"; - -/** - * Black pickup mascot — uses exact paths and filters from BlackIdelmascot.svg - * with the same bouncy squash-and-stretch animation as `mascot-yellow-pickup`, - * plus the idle bob, head-drift, arm-sway, and blink. - */ -export const BlackMascotPickup: React.FC = () => { - const frame = useCurrentFrame(); - const { fps, width, height } = useVideoConfig(); - const t = frame / fps; - - // Three bounces with decreasing squash + a small upward hop each peak. - const times = [0, 0.18, 0.36, 0.54, 0.72, 0.90, 1.08, 4.0]; - const sx = interpolate(t, times, [1, 1.18, 1, 1.12, 1, 1.06, 1, 1], { - extrapolateLeft: "clamp", - extrapolateRight: "clamp", - }); - const sy = interpolate(t, times, [1, 0.74, 1, 0.82, 1, 0.91, 1, 1], { - extrapolateLeft: "clamp", - extrapolateRight: "clamp", - }); - const ly = interpolate(t, times, [0, 0, -90, 0, -50, 0, -20, 0], { - extrapolateLeft: "clamp", - extrapolateRight: "clamp", - }); - - // Gentle bob for the whole character. - const bob = Math.sin((frame / fps) * Math.PI * 1.2) * 14; - - // Head dot drifts independently and squashes when pressing into the body. - const dotPhase = (frame / fps) * Math.PI * 1.0; - const dotDx = Math.sin(dotPhase * 0.7) * 6; - const dotDy = Math.sin(dotPhase) * 9; - const press = Math.max(0, Math.sin(dotPhase)); - const dotSquashY = 1 - 0.08 * press; - const dotSquashX = 1 + 0.05 * press; - - // Left arm gentle sway. - const leftSway = Math.sin((frame / fps) * Math.PI * 1.6) * 7; - // Steady right arm sway — mirrors left arm with slight phase offset. - const steadySway = Math.sin((frame / fps) * Math.PI * 1.6 + 0.3) * 6; - - // Blink every ~2.6s for ~6 frames. - const blinkPeriod = Math.round(fps * 2.6); - const blinkOffset = Math.round(blinkPeriod / 2); - const inBlink = (frame + blinkOffset) % blinkPeriod < 6; - const eyeScale = inBlink ? 0.12 : 1; - - const size = Math.min(width, height) * 0.85; - - return ( - - - - - {/* Ground shadow gradient */} - - - - - - {/* Body filter — from BlackIdelmascot.svg filter0_iig */} - - - - - - - - - - - - - - - - - - - - - {/* Head filter — from BlackIdelmascot.svg filter1_iig */} - - - - - - - - - - - - - - - - - - - - - {/* Neck shadow filters */} - - - - - - - - - - - - {/* Left arm filter — from BlackIdelmascot.svg filter4_iig */} - - - - - - - - - - - - - - - - - - - - - {/* Right steady arm filter — from BlackIdelmascot.svg filter5_iig */} - - - - - - - - - - - - - - - - - - - - - {/* Left eye highlight filters */} - - - - - - - - - - - - {/* Right eye highlight filters */} - - - - - - - - - - - - - - - - - {/* Cheek highlight filters */} - - - - - - - - - - - - - {/* Ground shadow */} - - - - - {/* Everything bobs together */} - - - {/* Head — drifts + squashes independently */} - - - - - - - {/* Body */} - - - - - {/* Right steady arm — gentle sway */} - - - - - - - {/* Left arm — gentle sway */} - - - - - - - {/* Neck shadows */} - - - - - - - - {/* Left eye — scaleY collapses on blink */} - - - {!inBlink && ( - <> - - - - - - - - )} - - - {/* Right eye — scaleY collapses on blink */} - - - {!inBlink && ( - <> - - - - - - - - - - - )} - - - {/* Left cheek */} - - - - - - {/* Right cheek */} - - - - - - {/* Mouth */} - - - - - - - ); -}; diff --git a/remotion/src/Mascot/mascot-black-sleep.tsx b/remotion/src/Mascot/mascot-black-sleep.tsx deleted file mode 100644 index f6d6b9d14..000000000 --- a/remotion/src/Mascot/mascot-black-sleep.tsx +++ /dev/null @@ -1,345 +0,0 @@ -import React from "react"; -import { AbsoluteFill, Easing, interpolate, useCurrentFrame, useVideoConfig } from "remotion"; - -/** - * Black sleep mascot — uses exact paths and filters from BlackIdelmascot.svg. - * Replicates `mascot-yellow-sleep`: blinks normally, then eyes slowly droop closed, - * then sleep-arc eyes appear and Zzz letters float upward. - */ -export const BlackMascotSleep: React.FC = () => { - const frame = useCurrentFrame(); - const { fps, width, height } = useVideoConfig(); - - // Gentle bob for the whole character. - const bob = Math.sin((frame / fps) * Math.PI * 1.2) * 14; - - // Head dot drifts independently and squashes when pressing into the body. - const dotPhase = (frame / fps) * Math.PI * 1.0; - const dotDx = Math.sin(dotPhase * 0.7) * 6; - const dotDy = Math.sin(dotPhase) * 9; - const press = Math.max(0, Math.sin(dotPhase)); - const dotSquashY = 1 - 0.08 * press; - const dotSquashX = 1 + 0.05 * press; - - // Left arm gentle sway. - const leftSway = Math.sin((frame / fps) * Math.PI * 1.6) * 7; - // Steady right arm sway. - const steadySway = Math.sin((frame / fps) * Math.PI * 1.6 + 0.3) * 6; - - // Normal blink every ~2.6s for ~6 frames. - const blinkPeriod = Math.round(fps * 2.6); - const blinkOffset = Math.round(blinkPeriod / 2); - const inBlink = (frame + blinkOffset) % blinkPeriod < 6; - const blinkScale = inBlink ? 0.12 : 1; - - // Sleep animation — slow eye-close then floating Zzz. - const sleepStartFrame = Math.round(fps * 2.5); - const sleepFullFrame = Math.round(fps * 4.0); - const inSleepTransition = frame >= sleepStartFrame; - const sleepProgress = interpolate(frame, [sleepStartFrame, sleepFullFrame], [0, 1], { - extrapolateLeft: "clamp", - extrapolateRight: "clamp", - easing: Easing.inOut(Easing.cubic), - }); - const isAsleep = frame >= sleepFullFrame; - - // Eye openness: normal blink while awake, slow droop during sleep transition. - const eyeScale = inSleepTransition ? Math.max(0, 1 - sleepProgress) : blinkScale; - // Suppress blink highlights mid-droop so pupils don't pop on/off. - const effectiveInBlink = inSleepTransition ? false : inBlink; - // Switch to sleep-arc eyes once eyelids have closed. - const showSleepEyes = eyeScale <= 0.06; - - // Floating Z letters — staggered, drift up and fade out. - const zPeriod = Math.round(fps * 2.2); - const zBaseStart = sleepFullFrame + Math.round(fps * 0.4); - const getZ = (delay: number, baseX: number, fontSize: number) => { - const startAt = zBaseStart + delay; - if (!isAsleep || frame < startAt) return { x: baseX, y: 220, opacity: 0, fontSize }; - const cycleFrame = (frame - startAt) % zPeriod; - const t = cycleFrame / zPeriod; - return { - x: baseX + t * 20, - y: 220 - t * 120, - opacity: interpolate(t, [0, 0.1, 0.72, 1], [0, 1, 0.85, 0]), - fontSize, - }; - }; - const z1 = getZ(0, 605, 40); - const z2 = getZ(Math.round(fps * 0.72), 624, 56); - const z3 = getZ(Math.round(fps * 1.44), 643, 76); - - const size = Math.min(width, height) * 0.85; - - return ( - - - - {/* Ground shadow gradient */} - - - - - - {/* Body filter — from BlackIdelmascot.svg filter0_iig */} - - - - - - - - - - - - - - - - - - - - - {/* Head filter — from BlackIdelmascot.svg filter1_iig */} - - - - - - - - - - - - - - - - - - - - - {/* Neck shadow filters */} - - - - - - - - - - - - {/* Left arm filter — from BlackIdelmascot.svg filter4_iig */} - - - - - - - - - - - - - - - - - - - - - {/* Right steady arm filter — from BlackIdelmascot.svg filter5_iig */} - - - - - - - - - - - - - - - - - - - - - {/* Left eye highlight filters */} - - - - - - - - - - - - {/* Right eye highlight filters */} - - - - - - - - - - - - - - - - - {/* Cheek highlight filters */} - - - - - - - - - - - - - {/* Ground shadow */} - - - - - {/* Everything bobs together */} - - - {/* Head — drifts + squashes independently */} - - - - - - - {/* Body */} - - - - - {/* Right steady arm — gentle sway */} - - - - - - - {/* Left arm — gentle sway */} - - - - - - - {/* Neck shadows */} - - - - - - - - {/* Sleep arc eyes — visible only once eyelids are fully closed */} - {showSleepEyes && ( - <> - - - - )} - - {/* Left eye — scaleY droops during sleep transition, hidden once sleep-arc shows */} - {!showSleepEyes && ( - - - {!effectiveInBlink && ( - <> - - - - - - - - )} - - )} - - {/* Right eye — scaleY droops during sleep transition, hidden once sleep-arc shows */} - {!showSleepEyes && ( - - - {!effectiveInBlink && ( - <> - - - - - - - - - - - )} - - )} - - {/* Left cheek */} - - - - - - {/* Right cheek */} - - - - - - {/* Mouth */} - - - - {/* Zzz — floating letters that drift up after mascot falls asleep */} - {isAsleep && ( - <> - Z - Z - Z - - )} - - - - ); -}; diff --git a/remotion/src/Mascot/mascot-black-talking.tsx b/remotion/src/Mascot/mascot-black-talking.tsx deleted file mode 100644 index 7491ea9c0..000000000 --- a/remotion/src/Mascot/mascot-black-talking.tsx +++ /dev/null @@ -1,300 +0,0 @@ -import React from "react"; -import { AbsoluteFill, useCurrentFrame, useVideoConfig } from "remotion"; - -/** - * Black talking mascot — uses exact paths and filters from BlackIdelmascot.svg - * with the same bob, head-drift, arm-sway, and blink as the black idle, - * but replaces the static mouth with a lip-sync jaw-drop animation. - */ -export const BlackMascotTalking: React.FC = () => { - const frame = useCurrentFrame(); - const { fps, width, height } = useVideoConfig(); - - // Gentle bob for the whole character. - const bob = Math.sin((frame / fps) * Math.PI * 1.2) * 14; - - // Head dot drifts independently and squashes when pressing into the body. - const dotPhase = (frame / fps) * Math.PI * 1.0; - const dotDx = Math.sin(dotPhase * 0.7) * 6; - const dotDy = Math.sin(dotPhase) * 9; - const press = Math.max(0, Math.sin(dotPhase)); - const dotSquashY = 1 - 0.08 * press; - const dotSquashX = 1 + 0.05 * press; - - // Left arm gentle sway. - const leftSway = Math.sin((frame / fps) * Math.PI * 1.6) * 7; - // Steady right arm sway — mirrors left arm with slight phase offset. - const steadySway = Math.sin((frame / fps) * Math.PI * 1.6 + 0.3) * 6; - - // Blink every ~2.6s for ~6 frames. - const blinkPeriod = Math.round(fps * 2.6); - const blinkOffset = Math.round(blinkPeriod / 2); - const inBlink = (frame + blinkOffset) % blinkPeriod < 6; - const eyeScale = inBlink ? 0.12 : 1; - - // Lip sync — ~1.5–2.3 Hz for natural speech pace. - const talkA = Math.abs(Math.sin((frame / fps) * Math.PI * 3.0)); - const talkB = Math.abs(Math.sin((frame / fps) * Math.PI * 4.6 + 1.2)); - const mouthOpen = Math.max(talkA, talkB * 0.8); - // Tongue fades in only when mouth is open enough. - const tongueOpacity = Math.min(1, Math.max(0, (mouthOpen - 0.15) / 0.35)); - - const size = Math.min(width, height) * 0.85; - - return ( - - - - {/* Ground shadow gradient */} - - - - - - {/* Body filter — from BlackIdelmascot.svg filter0_iig */} - - - - - - - - - - - - - - - - - - - - - {/* Head filter — from BlackIdelmascot.svg filter1_iig */} - - - - - - - - - - - - - - - - - - - - - {/* Neck shadow filters */} - - - - - - - - - - - - {/* Left arm filter — from BlackIdelmascot.svg filter4_iig */} - - - - - - - - - - - - - - - - - - - - - {/* Right steady arm filter — from BlackIdelmascot.svg filter5_iig */} - - - - - - - - - - - - - - - - - - - - - {/* Left eye highlight filters */} - - - - - - - - - - - - {/* Right eye highlight filters */} - - - - - - - - - - - - - - - - - {/* Cheek highlight filters */} - - - - - - - - - - - - - {/* Ground shadow */} - - - - - {/* Everything bobs together */} - - - {/* Head — drifts + squashes independently */} - - - - - - - {/* Body */} - - - - - {/* Right steady arm — gentle sway */} - - - - - - - {/* Left arm — gentle sway */} - - - - - - - {/* Neck shadows */} - - - - - - - - {/* Left eye — scaleY collapses on blink */} - - - {!inBlink && ( - <> - - - - - - - - )} - - - {/* Right eye — scaleY collapses on blink */} - - - {!inBlink && ( - <> - - - - - - - - - - - )} - - - {/* Left cheek */} - - - - - - {/* Right cheek */} - - - - - - {/* Talking mouth — pivot at top edge (y=508), scales downward like a jaw drop */} - - - - - - - - - ); -}; diff --git a/remotion/src/Mascot/mascot-black-thinking.tsx b/remotion/src/Mascot/mascot-black-thinking.tsx deleted file mode 100644 index c71327982..000000000 --- a/remotion/src/Mascot/mascot-black-thinking.tsx +++ /dev/null @@ -1,330 +0,0 @@ -import React from "react"; -import { AbsoluteFill, Easing, interpolate, useCurrentFrame, useVideoConfig } from "remotion"; - -/** - * Black thinking mascot — uses exact paths and filters from BlackIdelmascot.svg. - * Replicates `mascot-yellow-thinking`: starts idle then transitions into thinking pose — - * left arm raises toward chin, head tilts, eyes look up-left, smile becomes "hmm". - */ -export const BlackMascotThinking: React.FC = () => { - const frame = useCurrentFrame(); - const { fps, width, height } = useVideoConfig(); - - // Gentle bob for the whole character. - const bob = Math.sin((frame / fps) * Math.PI * 1.2) * 14; - - // Head dot drifts independently and squashes when pressing into the body. - const dotPhase = (frame / fps) * Math.PI * 1.0; - const dotDx = Math.sin(dotPhase * 0.7) * 6; - const dotDy = Math.sin(dotPhase) * 9; - const press = Math.max(0, Math.sin(dotPhase)); - const dotSquashY = 1 - 0.08 * press; - const dotSquashX = 1 + 0.05 * press; - - // Left arm gentle sway (idle baseline). - const leftSway = Math.sin((frame / fps) * Math.PI * 1.6) * 7; - // Steady right arm sway. - const steadySway = Math.sin((frame / fps) * Math.PI * 1.6 + 0.3) * 6; - - // Blink every ~2.6s for ~6 frames. - const blinkPeriod = Math.round(fps * 2.6); - const blinkOffset = Math.round(blinkPeriod / 2); - const inBlink = (frame + blinkOffset) % blinkPeriod < 6; - const eyeScale = inBlink ? 0.12 : 1; - - // Thinking transition — starts at 1s, fully in at 2s. - const thinkStartFrame = Math.round(fps * 1.0); - const thinkFullFrame = Math.round(fps * 2.0); - const thinkProgress = interpolate(frame, [thinkStartFrame, thinkFullFrame], [0, 1], { - extrapolateLeft: "clamp", - extrapolateRight: "clamp", - easing: Easing.inOut(Easing.cubic), - }); - const isThinking = thinkProgress >= 1; - - // Left arm raises toward body/chin, then gently oscillates. - const thinkArmOscillate = isThinking ? Math.sin((frame / fps) * Math.PI * 0.5) * 2 : 0; - const effectiveLeftSway = interpolate(thinkProgress, [0, 1], [leftSway, -128]) + thinkArmOscillate; - - // Head tilts slightly toward raised arm. - const headTilt = isThinking - ? -4.5 + Math.sin((frame / fps) * Math.PI * 0.38) * 1.8 - : interpolate(thinkProgress, [0, 1], [0, -4.5]); - - // Eyes drift up-left. - const thinkEyeX = thinkProgress * -6; - const thinkEyeY = thinkProgress * -9; - - const size = Math.min(width, height) * 0.85; - - return ( - - - - {/* Ground shadow gradient */} - - - - - - {/* Body filter — from BlackIdelmascot.svg filter0_iig */} - - - - - - - - - - - - - - - - - - - - - {/* Head filter — from BlackIdelmascot.svg filter1_iig */} - - - - - - - - - - - - - - - - - - - - - {/* Neck shadow filters */} - - - - - - - - - - - - {/* Left arm filter — from BlackIdelmascot.svg filter4_iig */} - - - - - - - - - - - - - - - - - - - - - {/* Right steady arm filter — from BlackIdelmascot.svg filter5_iig */} - - - - - - - - - - - - - - - - - - - - - {/* Left eye highlight filters */} - - - - - - - - - - - - {/* Right eye highlight filters */} - - - - - - - - - - - - - - - - - {/* Cheek highlight filters */} - - - - - - - - - - - - - {/* Ground shadow */} - - - - - {/* Everything bobs together */} - - - {/* Head — drifts + squashes independently */} - - - - - - - {/* Body */} - - - - - {/* Right steady arm — gentle sway */} - - - - - - - {/* Left arm — raises toward chin while thinking */} - - - - - - - {/* Neck shadows */} - - - - - - - - {/* Face — rotates for head tilt */} - - - {/* Left eye — gaze drifts up-left while thinking */} - - - - {!inBlink && ( - <> - - - - - - - - )} - - - - {/* Right eye — gaze drifts up-left while thinking */} - - - - {!inBlink && ( - <> - - - - - - - - - - - )} - - - - {/* Left cheek */} - - - - - - {/* Right cheek */} - - - - - - {/* Normal smile — fades out as thinking kicks in */} - - - - - - {/* "Hmm" mouth — asymmetric slight frown, fades in */} - - - - - - ); -}; diff --git a/remotion/src/Mascot/mascot-black-wave.tsx b/remotion/src/Mascot/mascot-black-wave.tsx deleted file mode 100644 index c6ef02572..000000000 --- a/remotion/src/Mascot/mascot-black-wave.tsx +++ /dev/null @@ -1,297 +0,0 @@ -import React from "react"; -import { AbsoluteFill, Easing, interpolate, useCurrentFrame, useVideoConfig } from "remotion"; - -/** - * Black wave mascot — uses exact paths and filters from BlackIdelmascot.svg - * for the body, head, and left arm. The right waving arm uses the same path as - * `mascot-yellow-wave` with #3A3A3A fill and black-tuned inner shadow filter. - * Same keyframe hi-wave animation: 3 swings then a rest pause, loops every 2.4s. - */ -export const BlackMascotWave: React.FC = () => { - const frame = useCurrentFrame(); - const { fps, width, height } = useVideoConfig(); - - // Gentle bob for the whole character. - const bob = Math.sin((frame / fps) * Math.PI * 1.2) * 14; - - // Head dot drifts independently and squashes when pressing into the body. - const dotPhase = (frame / fps) * Math.PI * 1.0; - const dotDx = Math.sin(dotPhase * 0.7) * 6; - const dotDy = Math.sin(dotPhase) * 9; - const press = Math.max(0, Math.sin(dotPhase)); - const dotSquashY = 1 - 0.08 * press; - const dotSquashX = 1 + 0.05 * press; - - // Right arm wave — 3 swings then rest, loops every 2.4s. - const easeInOut = Easing.inOut(Easing.cubic); - const wavePeriod = Math.round(fps * 2.4); - const frameInCycle = frame % wavePeriod; - const wave = interpolate( - frameInCycle, - [0, wavePeriod * 0.12, wavePeriod * 0.25, wavePeriod * 0.38, wavePeriod * 0.50, wavePeriod * 0.62, wavePeriod * 0.75, wavePeriod], - [0, -9, 0, -7, 0, -5, 0, 0], - { extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: easeInOut } - ); - - // Left arm gentle sway. - const leftSway = Math.sin((frame / fps) * Math.PI * 1.6) * 7; - - // Blink every ~2.6s for ~6 frames. - const blinkPeriod = Math.round(fps * 2.6); - const blinkOffset = Math.round(blinkPeriod / 2); - const inBlink = (frame + blinkOffset) % blinkPeriod < 6; - const eyeScale = inBlink ? 0.12 : 1; - - const size = Math.min(width, height) * 0.85; - - return ( - - - - {/* Ground shadow gradient */} - - - - - - {/* Body filter — from BlackIdelmascot.svg filter0_iig */} - - - - - - - - - - - - - - - - - - - - - {/* Head filter — from BlackIdelmascot.svg filter1_iig */} - - - - - - - - - - - - - - - - - - - - - {/* Neck shadow filters */} - - - - - - - - - - - - {/* Left arm filter — from BlackIdelmascot.svg filter4_iig */} - - - - - - - - - - - - - - - - - - - - - {/* Right wave arm filter — bounds from MascotCharacter f4, black inner shadow values */} - - - - - - - - - - - - - - - - - - - - - {/* Left eye highlight filters */} - - - - - - - - - - - - {/* Right eye highlight filters */} - - - - - - - - - - - - - - - - - {/* Cheek highlight filters */} - - - - - - - - - - - - - {/* Ground shadow */} - - - - - {/* Everything bobs together */} - - - {/* Head — drifts + squashes independently */} - - - - - - - {/* Body */} - - - - - {/* Right waving arm — rotates around pivot (776, 568) */} - - - - - - - {/* Left arm — gentle sway */} - - - - - - - {/* Neck shadows */} - - - - - - - - {/* Left eye — scaleY collapses on blink */} - - - {!inBlink && ( - <> - - - - - - - - )} - - - {/* Right eye — scaleY collapses on blink */} - - - {!inBlink && ( - <> - - - - - - - - - - - )} - - - {/* Left cheek */} - - - - - - {/* Right cheek */} - - - - - - {/* Mouth */} - - - - - - ); -}; diff --git a/remotion/src/Mascot/mascot-black-wink.tsx b/remotion/src/Mascot/mascot-black-wink.tsx deleted file mode 100644 index 0cf16a262..000000000 --- a/remotion/src/Mascot/mascot-black-wink.tsx +++ /dev/null @@ -1,314 +0,0 @@ -import React from "react"; -import { - AbsoluteFill, - Easing, - interpolate, - useCurrentFrame, - useVideoConfig, -} from "remotion"; - -export const BlackMascotWink: React.FC = () => { - const frame = useCurrentFrame(); - const { fps, width, height } = useVideoConfig(); - const p = (k: string) => `bmwk-${k}`; - - // ── Relaxed body bob ───────────────────────────────────────────────────── - const bob = Math.sin((frame / fps) * Math.PI * 1.0) * 10; - - // ── Head drift + squash ───────────────────────────────────────────────── - const dotPhase = (frame / fps) * Math.PI; - const headDx = Math.sin(dotPhase * 0.7) * 5; - const headDy = Math.sin(dotPhase) * 7; - const press = Math.max(0, Math.sin(dotPhase)); - const headSquashY = 1 - 0.07 * press; - const headSquashX = 1 + 0.05 * press; - - // ── Wink transition: right eye open → wink ────────────────────────────── - const winkProgress = interpolate(frame, [60, 78], [0, 1], { - easing: Easing.inOut(Easing.quad), - extrapolateLeft: "clamp", - extrapolateRight: "clamp", - }); - const openRightEyeOpacity = 1 - winkProgress; - const winkEyeOpacity = winkProgress; - - // Slight head tilt as wink comes in - const headTilt = interpolate(frame, [60, 85], [0, 4], { - easing: Easing.out(Easing.quad), - extrapolateLeft: "clamp", - extrapolateRight: "clamp", - }); - - // ── Left eye blink — only after wink is set (frame 95+) ───────────────── - const blinkPeriod = Math.round(fps * 3.5); - const blinkDur = Math.round(fps * 0.14); - const blinkOffset = frame < 95 ? 0 : (frame - 95) % blinkPeriod; - const leftEyeScaleY = - blinkOffset < blinkDur - ? interpolate( - blinkOffset, - [0, blinkDur * 0.35, blinkDur * 0.65, blinkDur], - [1, 0.06, 0.06, 1], - { extrapolateLeft: "clamp", extrapolateRight: "clamp" } - ) - : 1; - - // ── Right arm — waves only after wink is set ──────────────────────────── - const rightArmWave = interpolate(frame, [70, 95], [0, 1], { - easing: Easing.out(Easing.quad), - extrapolateLeft: "clamp", - extrapolateRight: "clamp", - }); - const rightArmAngle = - (10 + Math.sin((frame / fps) * Math.PI * 2.5) * 22) * rightArmWave; - - // ── Left arm — gentle idle sway ───────────────────────────────────────── - const leftArmAngle = Math.sin((frame / fps) * Math.PI * 0.9) * 7; - - const size = Math.min(width, height) * 0.82; - - return ( - - - - - - - - - {/* Body */} - - - - - - - - - - - - - - - - - - {/* Head circle */} - - - - - - - - - - - - - - - - - - {/* Neck shadows */} - - - - - - - - - - - - {/* Left arm */} - - - - - - - - - - - - - - - - - - {/* Right arm */} - - - - - - - - - - - - - - - - - - {/* Left eye highlights */} - - - - - - - - - - - - {/* Right open eye highlights */} - - - - - - - - - - - - {/* Cheek highlights */} - - - - - - - - - - - - - {/* Ground shadow */} - - - {/* Everything bobs */} - - - {/* Body */} - - - {/* Left arm */} - - - - - {/* Right arm — waves after wink */} - - - - - {/* Head group: drift + tilt + squash */} - - - - {/* Neck shadows */} - - - - - - - - {/* Head circle */} - - - {/* Left eye — blinks periodically after wink */} - - - - - - - - - - - {/* Right eye: open (fades out) → wink (fades in) */} - - - - - - - - - - - {/* Wink right eye — stroke arch */} - - - - - {/* Left cheek */} - - - - - - {/* Right cheek */} - - - - - - {/* Smirk mouth */} - - - - - - {/* end head group */} - - - {/* end bob group */} - - - - ); -}; diff --git a/remotion/src/Mascot/mascot-yellow-boba-tea-holding.tsx b/remotion/src/Mascot/mascot-yellow-boba-tea-holding.tsx deleted file mode 100644 index 466ec2b2e..000000000 --- a/remotion/src/Mascot/mascot-yellow-boba-tea-holding.tsx +++ /dev/null @@ -1,285 +0,0 @@ -import React from "react"; -import { - AbsoluteFill, - interpolate, - useCurrentFrame, - useVideoConfig, -} from "remotion"; - -/** - * NewMascotBobateaHolding — Boobateaholding.svg idle animation. - */ -export const NewMascotBobateaHolding: React.FC = () => { - const frame = useCurrentFrame(); - const { fps, width, height } = useVideoConfig(); - const p = (k: string) => `nmbh-${k}`; - - const bob = Math.sin((frame / fps) * Math.PI * 1.2) * 10; - const dotPhase = (frame / fps) * Math.PI; - const headDx = Math.sin(dotPhase * 0.7) * 5; - const headDy = Math.sin(dotPhase) * 7; - const press = Math.max(0, Math.sin(dotPhase)); - const headSquashY = 1 - 0.08 * press; - const headSquashX = 1 + 0.05 * press; - const leftArmAngle = Math.sin((frame / fps) * Math.PI * 0.8) * 6; - const rightArmAngle = Math.sin((frame / fps) * Math.PI * 0.8 + Math.PI) * 6; - const cupSway = Math.sin((frame / fps) * Math.PI * 0.9) * 2.5; - - const blinkPeriod = Math.round(fps * 3.5); - const blinkDur = Math.round(fps * 0.13); - const blinkPhase = frame % blinkPeriod; - const eyeScaleY = - blinkPhase < blinkDur - ? interpolate( - blinkPhase, - [0, blinkDur * 0.35, blinkDur * 0.65, blinkDur], - [1, 0.06, 0.06, 1], - { extrapolateLeft: "clamp", extrapolateRight: "clamp" } - ) - : 1; - - const size = Math.min(width, height) * 0.82; - - return ( - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - {/* Body */} - - - {/* Left arm */} - - - - - {/* Boba tea cup */} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - {/* Right arm */} - - - - - {/* Head group */} - - - - - - - - - - - - - {/* Left eye */} - - - - - - - {/* Right eye */} - - - - - - - - {/* Cheeks */} - - - - - - {/* Mouth */} - - - - - - - - - - ); -}; diff --git a/remotion/src/Mascot/mascot-yellow-book-reading.tsx b/remotion/src/Mascot/mascot-yellow-book-reading.tsx deleted file mode 100644 index b6d0d7975..000000000 --- a/remotion/src/Mascot/mascot-yellow-book-reading.tsx +++ /dev/null @@ -1,325 +0,0 @@ -import React from "react"; -import { - AbsoluteFill, - interpolate, - useCurrentFrame, - useVideoConfig, -} from "remotion"; - -/** - * NewMascotBookReading — Bookreading.svg brought to life. - * - * • Book gently sways left-right as the character reads - * • Both arms move in sync with the book sway - * • Head leans slightly forward (toward book), slow nod - * • Both eyes open with periodic blink - * • Calm slow body bob (focused/relaxed reading) - */ -export const NewMascotBookReading: React.FC = () => { - const frame = useCurrentFrame(); - const { fps, width, height } = useVideoConfig(); - const p = (k: string) => `nmbr-${k}`; - - // ── Slow calm body bob — 0.7 Hz ───────────────────────────────────────── - const bob = Math.sin((frame / fps) * Math.PI * 0.7) * 8; - - // ── Head drift — leans slightly toward book (+8 downward) ─────────────── - const dotPhase = (frame / fps) * Math.PI; - const headDx = Math.sin(dotPhase * 0.6) * 4; - const headDy = Math.sin(dotPhase * 0.7) * 6 + 8; - const press = Math.max(0, Math.sin(dotPhase * 0.7)); - const headSquashY = 1 - 0.05 * press; - const headSquashX = 1 + 0.035 * press; - - // ── Slow head nod — engaged in reading ────────────────────────────────── - const headNod = Math.sin((frame / fps) * Math.PI * 0.35) * 2.5; - - // ── Book sway — gentle 0.4 Hz rock around book center ─────────────────── - const bookSway = Math.sin((frame / fps) * Math.PI * 0.4) * 2.8; - - // ── Left arm moves with book (pivot at left shoulder ~313, 640) ───────── - const leftArmSway = bookSway * 0.75; - - // ── Right arm moves with book (pivot at right shoulder ~553, 682) ──────── - const rightArmSway = bookSway * 0.6; - - // ── Both eyes blink together every ~4s ────────────────────────────────── - const blinkPeriod = Math.round(fps * 4.0); - const blinkDur = Math.round(fps * 0.12); - const blinkPhase = frame % blinkPeriod; - const eyeScaleY = - blinkPhase < blinkDur - ? interpolate( - blinkPhase, - [0, blinkDur * 0.35, blinkDur * 0.65, blinkDur], - [1, 0.06, 0.06, 1], - { extrapolateLeft: "clamp", extrapolateRight: "clamp" } - ) - : 1; - - const size = Math.min(width, height) * 0.82; - - return ( - - - - - - - - - {/* Body */} - - - - - - - - - - - - - - - - - - {/* Head circle */} - - - - - - - - - - - - - - - - - - {/* Neck shadows */} - - - - - - - - - - - - {/* Left arm (book-holding) */} - - - - - - - - - - - - - - - - - - {/* Right arm (book-holding) */} - - - - - - - - - - - - - - - - - - {/* Left eye highlight */} - - - - - - - - - - - - {/* Right eye highlight */} - - - - - - - - - - - - - - - - - {/* Cheek highlights */} - - - - - - - - - - - - - {/* Ground shadow */} - - - {/* ── Everything bobs ───────────────────────────────────────────────── */} - - - {/* ── Body ────────────────────────────────────────────────────────── */} - - - {/* ── Left arm + book + right arm sway together ────────────────────── */} - - - {/* Left arm */} - - - - - {/* Right arm */} - - - - - {/* ── Book ────────────────────────────────────────────────────────── */} - {/* Main book cover (dark olive) */} - - {/* White pages */} - - {/* Page details */} - - - - - - {/* Spine connector + decorative details */} - - - - - - {/* end book + arms sway group */} - - {/* ── Head group: drift + nod + squash ────────────────────────────── */} - - - - {/* Neck shadows */} - - - - - - - - {/* Head circle */} - - - {/* Left eye — blinks */} - - - - - - - - - - - {/* Right eye — blinks */} - - - - - - - - - - - - - - {/* Left cheek */} - - - - - - {/* Right cheek */} - - - - - - {/* Focused mouth */} - - - - - - {/* end head group */} - - - {/* end bob group */} - - - - ); -}; diff --git a/remotion/src/Mascot/mascot-yellow-celebrate.tsx b/remotion/src/Mascot/mascot-yellow-celebrate.tsx deleted file mode 100644 index 0b4191bad..000000000 --- a/remotion/src/Mascot/mascot-yellow-celebrate.tsx +++ /dev/null @@ -1,374 +0,0 @@ -import React from "react"; -import { - AbsoluteFill, - interpolate, - useCurrentFrame, - useVideoConfig, -} from "remotion"; - -/** - * NewMascotCelebrate — full celebrate.svg brought to life. - * - * All paths from celebrate.svg are included as-is: - * • Cone hat cluster (top-right) — wobbles with the head - * • Cone horn in raised right arm — waves with the arm - * • Scattered party confetti pieces — sparkle/pulse - * • Happy ^^ eyes + open mouth + tongue - * - * No idle transition — animation starts straight in celebrate mode. - */ -export const NewMascotCelebrate: React.FC = () => { - const frame = useCurrentFrame(); - const { fps, width, height } = useVideoConfig(); - const p = (k: string) => `nmcl-${k}`; - - // ── Body bob — energetic 2 Hz bounce ──────────────────────────────────── - const bob = Math.sin((frame / fps) * Math.PI * 2.0) * 16; - - // ── Head drift + squash ────────────────────────────────────────────────── - const dotPhase = (frame / fps) * Math.PI; - const headDx = Math.sin(dotPhase * 0.7) * 6; - const headDy = Math.sin(dotPhase) * 9; - const press = Math.max(0, Math.sin(dotPhase)); - const headSquashY = 1 - 0.09 * press; - const headSquashX = 1 + 0.06 * press; - - // ── Hat wobble — pivots at the base where cone meets body/head ─────────── - const hatWobble = Math.sin((frame / fps) * Math.PI * 1.4) * 3.5; - - // ── Left arm — enthusiastic wave ──────────────────────────────────────── - const leftArmAngle = - -50 + Math.sin((frame / fps) * Math.PI * 3.0) * 18; - - // ── Right arm + horn — wave together ──────────────────────────────────── - const hornWave = Math.sin((frame / fps) * Math.PI * 2.5 + 0.5) * 8; - - // ── Confetti sparkle pulses (different phase per group) ───────────────── - const sp = (phase: number) => - 0.62 + Math.sin((frame / fps) * Math.PI * 1.8 + phase) * 0.32; - - // ── Falling confetti particles ─────────────────────────────────────────── - const fallingDefs = [ - { delay: 0, x: 130, drift: 22, color: "#3C5FAD", w: 12, h: 8 }, - { delay: 8, x: 255, drift: -17, color: "#FBD387", w: 9, h: 9 }, - { delay: 3, x: 375, drift: 25, color: "#B1D37E", w: 11, h: 7 }, - { delay: 14, x: 495, drift: -20, color: "#929ED3", w: 8, h: 10 }, - { delay: 1, x: 630, drift: 18, color: "#F5A29A", w: 10, h: 8 }, - { delay: 17, x: 750, drift: -26, color: "#EDB371", w: 9, h: 11 }, - { delay: 6, x: 850, drift: 15, color: "#B2ACD2", w: 12, h: 7 }, - { delay: 11, x: 190, drift: 30, color: "#FDB1AF", w: 8, h: 9 }, - { delay: 2, x: 695, drift: -12, color: "#3C5FAD", w: 11, h: 8 }, - { delay: 19, x: 440, drift: 23, color: "#FBD387", w: 9, h: 10 }, - ]; - const fallPeriod = Math.round(fps * 2.2); - const getFall = (delay: number, startX: number, driftX: number) => { - if (frame < delay) return { x: startX, y: -80, rotation: 0, opacity: 0 }; - const c = (frame - delay) % fallPeriod; - const t = c / fallPeriod; - return { - x: startX + driftX * t + Math.sin(t * Math.PI * 5) * 13, - y: -80 + 1100 * t, - rotation: t * 540, - opacity: interpolate(t, [0, 0.04, 0.82, 1], [0, 1, 0.88, 0], { - extrapolateLeft: "clamp", extrapolateRight: "clamp", - }), - }; - }; - - const size = Math.min(width, height) * 0.82; - - return ( - - - - - - - - - {/* Body */} - - - - - - - - - - - - - - - - - - {/* Head circle */} - - - - - - - - - - - - - - - - - - {/* Neck shadows */} - - - - - - - - - - - - {/* Left arm */} - - - - - - - - - - - - - - - - - - {/* Raised right arm */} - - - - - - - - - - - - - - - - - - {/* Cheek highlights */} - - - - - - - - - - - - - {/* Falling confetti rain */} - {fallingDefs.map((cd, i) => { - const ft = getFall(cd.delay, cd.x, cd.drift); - return ( - - ); - })} - - {/* Ground shadow */} - - - {/* ── Everything bobs together ────────────────────────────────────── */} - - - {/* ── Cone hat cluster — tracks head drift, wobbles at base ──────── */} - {/* Pivot at (563, 259): the bottom-left corner where hat meets body */} - - {/* Main cone */} - - {/* Shading accent */} - - {/* Star at tip */} - - - {/* Sparkle circles trailing down from cone */} - - - - - - - - - - - - {/* Dark tube connecting cone to body */} - - - - {/* ── Body ────────────────────────────────────────────────────────── */} - - - {/* ── Left arm — waves enthusiastically ───────────────────────────── */} - - - - - {/* ── Cone horn in hand + raised right arm — wave together ─────────── */} - - {/* Horn cone */} - - {/* Shading accent */} - - {/* Star on horn */} - - - {/* Sparkle circles from horn */} - - - - - - - - - - - - {/* Horn base / tube connecting to arm */} - - {/* Raised right arm */} - - - - {/* ── Scattered party confetti pieces — sparkle in groups ──────────── */} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - {/* ── Head group: drift + squash ───────────────────────────────────── */} - - - {/* Neck shadows */} - - - - - - - - {/* Head circle */} - - - {/* Happy ^^ eyes */} - - - - {/* Left cheek */} - - - - - - {/* Right cheek */} - - - - - - {/* Open mouth + tongue */} - - - - - {/* end head group */} - - - {/* end bob group */} - - - - ); -}; diff --git a/remotion/src/Mascot/mascot-yellow-crying.tsx b/remotion/src/Mascot/mascot-yellow-crying.tsx deleted file mode 100644 index 1cd047e6f..000000000 --- a/remotion/src/Mascot/mascot-yellow-crying.tsx +++ /dev/null @@ -1,371 +0,0 @@ -import React from "react"; -import { - AbsoluteFill, - interpolate, - useCurrentFrame, - useVideoConfig, -} from "remotion"; - -/** - * NewMascotCrying — full-loop crying animation. - * - * Crying eye and mouth paths are taken directly from Crying.svg. - */ -export const NewMascotCrying: React.FC = () => { - const frame = useCurrentFrame(); - const { fps, width, height } = useVideoConfig(); - const p = (k: string) => `nmcry-${k}`; - - const cryProgress = 1; - const normalFaceOpacity = 0; - const cryFaceOpacity = 1; - - // ── Body bob — calm idle blends into faster sob shudder ─────────────────── - const idleBob = Math.sin((frame / fps) * Math.PI * 1.2) * 14; - const sobBob = - Math.sin((frame / fps) * Math.PI * 2.8) * 10 + - Math.sin((frame / fps) * Math.PI * 5.5 + 0.7) * 3; - const bob = idleBob * (1 - cryProgress) + sobBob * cryProgress; - - // ── Head drift + squash (dampens as crying takes over) ─────────────────── - const dotPhase = (frame / fps) * Math.PI; - const driftScale = 1 - cryProgress * 0.65; - const headDx = Math.sin(dotPhase * 0.7) * 6 * driftScale; - const headDy = Math.sin(dotPhase) * 9 * driftScale; - const press = Math.max(0, Math.sin(dotPhase)) * driftScale; - const headSquashY = 1 - 0.08 * press; - const headSquashX = 1 + 0.05 * press; - - // ── Arms — gentle idle sway, droop down when crying ────────────────────── - const leftSway = Math.sin((frame / fps) * Math.PI * 1.3) * 7; - const rightSway = Math.sin((frame / fps) * Math.PI * 1.3 + 1.0) * 6; - const leftArmAngle = leftSway + cryProgress * 14; - const rightArmAngle = rightSway + cryProgress * 14; - - // ── Blink — only during idle phase; suppressed once crying starts ───────── - const blinkPeriod = Math.round(fps * 2.6); - const blinkOffset = Math.round(blinkPeriod / 2); - const inBlink = cryProgress < 0.15 && (frame + blinkOffset) % blinkPeriod < 6; - const eyeScaleNormal = inBlink ? 0.12 : 1; - - // ── Cheeks — flush more as crying intensifies ───────────────────────────── - const cheekOpacity = - 0.82 + cryProgress * 0.16 + Math.sin((frame / fps) * Math.PI * 1.1) * 0.05; - - // ── Tears — two streams per eye, staggered start and loop ──────────────── - // Tears live in the bob group (outside head squash group) so they fall - // straight down, moving with the body bob but not distorted by head squash. - const tearPeriod = Math.round(fps * 1.6); - - const getTear = (delayFrames: number, eyeX: number, eyeStartY: number) => { - const startAt = delayFrames; - if (frame < startAt) return { x: eyeX, y: eyeStartY, opacity: 0 }; - const cycleFrame = (frame - startAt) % tearPeriod; - const t = cycleFrame / tearPeriod; - const y = eyeStartY + t * 170; - const opacity = - interpolate(t, [0, 0.07, 0.68, 1.0], [0, 0.9, 0.75, 0], { - extrapolateLeft: "clamp", - extrapolateRight: "clamp", - }) * cryProgress; - return { x: eyeX, y, opacity }; - }; - - // Left eye bottom ~(395, 483) and (408, 483) in 1000×1000 space - const tL1 = getTear(0, 395, 485); - const tL2 = getTear(Math.round(fps * 0.55), 408, 485); - // Right eye bottom ~(590, 483) and (603, 484) - const tR1 = getTear(Math.round(fps * 0.22), 592, 485); - const tR2 = getTear(Math.round(fps * 0.80), 603, 485); - - const size = Math.min(width, height) * 0.82; - - return ( - - - - {/* Ground shadow */} - - - - - - {/* Body */} - - - - - - - - - - - - - - - - - - {/* Head circle */} - - - - - - - - - - - - - - - - - - {/* Neck shadows */} - - - - - - - - - - - - {/* Left arm */} - - - - - - - - - - - - - - - - - - {/* Right arm */} - - - - - - - - - - - - - - - - - - {/* Normal left eye highlights */} - - - - - - - - - - - - {/* Normal right eye highlights */} - - - - - - - - - - - - - - - - - {/* Cheek highlights */} - - - - - - - - - - - - - {/* Ground shadow */} - - - {/* Everything bobs together */} - - - {/* Body */} - - - {/* Left arm — droops slightly when crying */} - - - - - {/* Right arm — droops slightly when crying */} - - - - - {/* Tears — positioned at eye bottoms, outside head squash group */} - {[tL1, tL2, tR1, tR2].map((t, i) => ( - - {/* Teardrop: rounded top + pointed bottom */} - - - ))} - - {/* Head group: drift + squash */} - - - {/* Neck shadows */} - - - - - - - - {/* Head circle */} - - - {/* Normal eyes — fade out as crying begins */} - - {/* Left eye */} - - - - - - - - - - - {/* Right eye */} - - - - - - - - - - - - - - - {/* Crying eyes — squinted V-shapes from Crying.svg, fade in */} - - {/* Left squinting eye */} - - {/* Right squinting eye */} - - - - {/* Cheeks — get more flushed while crying */} - - - - - - - - - - - - - - {/* Normal smile — fades out */} - - - - - - {/* Sad frown mouth — fades in, from Crying.svg */} - - - - - - - {/* end head group */} - - - {/* end bob group */} - - - - ); -}; diff --git a/remotion/src/Mascot/mascot-yellow-cup-holding.tsx b/remotion/src/Mascot/mascot-yellow-cup-holding.tsx deleted file mode 100644 index 373c38e73..000000000 --- a/remotion/src/Mascot/mascot-yellow-cup-holding.tsx +++ /dev/null @@ -1,314 +0,0 @@ -import React from "react"; -import { - AbsoluteFill, - interpolate, - useCurrentFrame, - useVideoConfig, -} from "remotion"; - -/** - * NewMascotCupHolding — Cupholding.svg idle animation. - * - * Idle pattern: - * • Smooth body bob (1.2 Hz) - * • Head drift + squash/stretch - * • Cup held between arms, gentle sway with body - * • Left + right arms sway in opposite phases - * • Both eyes blink every ~3.5s - */ -export const NewMascotCupHolding: React.FC = () => { - const frame = useCurrentFrame(); - const { fps, width, height } = useVideoConfig(); - const p = (k: string) => `nmch-${k}`; - - // ── Body bob — idle 1.2 Hz ─────────────────────────────────────────────── - const bob = Math.sin((frame / fps) * Math.PI * 1.2) * 10; - - // ── Head drift + squash ───────────────────────────────────────────────── - const dotPhase = (frame / fps) * Math.PI; - const headDx = Math.sin(dotPhase * 0.7) * 5; - const headDy = Math.sin(dotPhase) * 7; - const press = Math.max(0, Math.sin(dotPhase)); - const headSquashY = 1 - 0.08 * press; - const headSquashX = 1 + 0.05 * press; - - // ── Left arm — gentle idle sway ────────────────────────────────────────── - const leftArmAngle = Math.sin((frame / fps) * Math.PI * 0.8) * 6; - - // ── Right arm — opposite phase ─────────────────────────────────────────── - const rightArmAngle = Math.sin((frame / fps) * Math.PI * 0.8 + Math.PI) * 6; - - // ── Cup — gentle sway with body ────────────────────────────────────────── - const cupSway = Math.sin((frame / fps) * Math.PI * 0.9) * 2.5; - - // ── Blink — both eyes every ~3.5s ─────────────────────────────────────── - const blinkPeriod = Math.round(fps * 3.5); - const blinkDur = Math.round(fps * 0.13); - const blinkPhase = frame % blinkPeriod; - const eyeScaleY = - blinkPhase < blinkDur - ? interpolate( - blinkPhase, - [0, blinkDur * 0.35, blinkDur * 0.65, blinkDur], - [1, 0.06, 0.06, 1], - { extrapolateLeft: "clamp", extrapolateRight: "clamp" } - ) - : 1; - - const size = Math.min(width, height) * 0.82; - - return ( - - - - - - - - - {/* Body */} - - - - - - - - - - - - - - - - - - {/* Head circle */} - - - - - - - - - - - - - - - - - - {/* Neck shadows */} - - - - - - - - - - - - {/* Left arm */} - - - - - - - - - - - - - - - - - - {/* Right arm */} - - - - - - - - - - - - - - - - - - {/* Left eye highlight */} - - - - - - - - - - - - {/* Right eye highlight */} - - - - - - - - - - - - - - - - - {/* Cheek highlights */} - - - - - - - - - - - - - {/* Ground shadow */} - - - {/* ── Everything bobs ───────────────────────────────────────────────── */} - - - {/* ── Body ────────────────────────────────────────────────────────── */} - - - {/* ── Left arm ─────────────────────────────────────────────────────── */} - - - - - {/* ── Cup — gentle sway with body ─────────────────────────────────── */} - - - - - - - - - - - - - {/* ── Right arm — opposite phase ──────────────────────────────────── */} - - - - - {/* ── Head group: drift + squash ───────────────────────────────────── */} - - - - {/* Neck shadows */} - - - - - - - - {/* Head circle */} - - - {/* Left eye — blinks */} - - - - - - - - - - - {/* Right eye — blinks */} - - - - - - - - - - - - - - {/* Left cheek */} - - - - - - {/* Right cheek */} - - - - - - {/* Mouth */} - - - - - - {/* end head group */} - - - {/* end bob group */} - - - - ); -}; diff --git a/remotion/src/Mascot/mascot-yellow-greeting.tsx b/remotion/src/Mascot/mascot-yellow-greeting.tsx deleted file mode 100644 index e9d85e873..000000000 --- a/remotion/src/Mascot/mascot-yellow-greeting.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import React from "react"; -import { z } from "zod"; -import { MascotCharacter, mascotSchema } from "./lib"; - -export const mascotGreetingSchema = mascotSchema.extend({ - greeting: z.boolean().default(true), -}); -export type MascotGreetingProps = z.infer; - -// Variant: starts idle, right arm rises up, then waves "hi" continuously. -export const MascotGreeting: React.FC = (props) => ( - -); diff --git a/remotion/src/Mascot/mascot-yellow-hat-with-bag.tsx b/remotion/src/Mascot/mascot-yellow-hat-with-bag.tsx deleted file mode 100644 index 2b52f5cf6..000000000 --- a/remotion/src/Mascot/mascot-yellow-hat-with-bag.tsx +++ /dev/null @@ -1,364 +0,0 @@ -import React from "react"; -import { - AbsoluteFill, - interpolate, - useCurrentFrame, - useVideoConfig, -} from "remotion"; - -/** - * NewMascotHatWithBag — hatwithbag.svg idle animation. - * - * Idle pattern: - * • Smooth body bob (1.2 Hz) - * • Head drift + squash/stretch - * • Hat tracks head drift (inside head group) - * • Bag has gentle pendulum sway (slight phase lag) - * • Left + right arms sway in opposite phases - * • Both eyes blink every ~3.5s - */ -export const NewMascotHatWithBag: React.FC = () => { - const frame = useCurrentFrame(); - const { fps, width, height } = useVideoConfig(); - const p = (k: string) => `nmhb-${k}`; - - // ── Body bob — idle 1.2 Hz ─────────────────────────────────────────────── - const bob = Math.sin((frame / fps) * Math.PI * 1.2) * 10; - - // ── Head drift + squash ───────────────────────────────────────────────── - const dotPhase = (frame / fps) * Math.PI; - const headDx = Math.sin(dotPhase * 0.7) * 5; - const headDy = Math.sin(dotPhase) * 7; - const press = Math.max(0, Math.sin(dotPhase)); - const headSquashY = 1 - 0.08 * press; - const headSquashX = 1 + 0.05 * press; - - // ── Left arm — gentle idle sway ────────────────────────────────────────── - const leftArmAngle = Math.sin((frame / fps) * Math.PI * 0.8) * 8; - - // ── Right arm — opposite phase ─────────────────────────────────────────── - const rightArmAngle = Math.sin((frame / fps) * Math.PI * 0.8 + Math.PI) * 8; - - // ── Bag pendulum — hangs naturally, slight phase lag behind body ────────── - const bagSwing = Math.sin((frame / fps) * Math.PI * 1.0 + 0.45) * 3; - - // ── Blink — both eyes every ~3.5s ─────────────────────────────────────── - const blinkPeriod = Math.round(fps * 3.5); - const blinkDur = Math.round(fps * 0.13); - const blinkPhase = frame % blinkPeriod; - const eyeScaleY = - blinkPhase < blinkDur - ? interpolate( - blinkPhase, - [0, blinkDur * 0.35, blinkDur * 0.65, blinkDur], - [1, 0.06, 0.06, 1], - { extrapolateLeft: "clamp", extrapolateRight: "clamp" } - ) - : 1; - - const size = Math.min(width, height) * 0.82; - - return ( - - - - - - - - - {/* Body */} - - - - - - - - - - - - - - - - - - {/* Head circle */} - - - - - - - - - - - - - - - - - - {/* Neck shadows */} - - - - - - - - - - - - {/* Left arm */} - - - - - - - - - - - - - - - - - - {/* Left eye highlight */} - - - - - - - - - - - - {/* Right eye highlight */} - - - - - - - - - - - - - - - - - {/* Cheek highlights */} - - - - - - - - - - - - {/* Right arm */} - - - - - - - - - - - - - - - - - - {/* Hat shadow */} - - - - - - - {/* Hat brim buckle details */} - - - - - - - - - - - - - {/* Ground shadow */} - - - {/* ── Everything bobs ───────────────────────────────────────────────── */} - - - {/* ── Body ────────────────────────────────────────────────────────── */} - - - {/* ── Left arm — gentle idle sway ─────────────────────────────────── */} - - - - - {/* ── Bag — gentle pendulum sway (rotate around upper strap point) ── */} - - {/* Bag strap (line 53) */} - - - {/* Main bag body + cross-body strap */} - - {/* Bag shadow */} - - {/* Bag clasp/hardware */} - - - - - - {/* Bag buckle dots */} - - - - {/* end bag sway group */} - - {/* ── Right arm — opposite phase idle sway ─────────────────────────── */} - - - - - {/* ── Head group: drift + squash ───────────────────────────────────── */} - {/* Hat sits outside squash group so it translates with head but isn't squashed */} - - - {/* ── Hat cluster — moves with head drift ──────────────────────────── */} - {/* Main hat shape */} - - {/* Hat shadow */} - - - - {/* Hat brim band + buckle */} - - - - - - - - - - - {/* ── Head content: squash/stretch ────────────────────────────────── */} - - - {/* Neck shadows */} - - - - - - - - {/* Head circle */} - - - {/* Left eye — blinks */} - - - - - - - - - - - {/* Right eye — blinks */} - - - - - - - - - - - - - - {/* Left cheek */} - - - - - - {/* Right cheek */} - - - - - - {/* Smirk mouth */} - - - - - {/* end squash group */} - - {/* end head drift group */} - - - {/* end bob group */} - - - - ); -}; diff --git a/remotion/src/Mascot/mascot-yellow-idle.tsx b/remotion/src/Mascot/mascot-yellow-idle.tsx deleted file mode 100644 index 169285a3a..000000000 --- a/remotion/src/Mascot/mascot-yellow-idle.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import React from "react"; -import { MascotCharacter, mascotSchema, type MascotProps } from "./lib"; - -// Variant: idle mascot (no arm wave). -export const yellowMascotIdleSchema = mascotSchema; -export type YellowMascotIdleProps = MascotProps; - -export const YellowMascotIdle: React.FC = (props) => ( - -); diff --git a/remotion/src/Mascot/mascot-yellow-laughing.tsx b/remotion/src/Mascot/mascot-yellow-laughing.tsx deleted file mode 100644 index 70bcaefbc..000000000 --- a/remotion/src/Mascot/mascot-yellow-laughing.tsx +++ /dev/null @@ -1,236 +0,0 @@ -import React from "react"; -import { - AbsoluteFill, - useCurrentFrame, - useVideoConfig, -} from "remotion"; - -/** - * NewMascotLaughing — laughing.svg brought to life. - * - * Both arms wave out-of-phase with laughter. - * Body bounces rapidly + shakes horizontally. - * Head tilts side-to-side. - * Same happy face as celebrate (^^ eyes, open mouth + tongue). - * Starts straight in laughing mode — no idle transition. - */ -export const NewMascotLaughing: React.FC = () => { - const frame = useCurrentFrame(); - const { fps, width, height } = useVideoConfig(); - const p = (k: string) => `nmla-${k}`; - - // ── Body bounce — rapid 3 Hz laughter bounce ──────────────────────────── - const bob = Math.sin((frame / fps) * Math.PI * 3.0) * 18; - - // ── Horizontal body wobble — small 5 Hz side shake ────────────────────── - const wobble = Math.sin((frame / fps) * Math.PI * 5.0) * 5; - - // ── Head drift + squash ───────────────────────────────────────────────── - const dotPhase = (frame / fps) * Math.PI; - const headDx = Math.sin(dotPhase * 2.5) * 8 + wobble * 0.5; - const headDy = Math.sin(dotPhase * 3.0) * 9; - const press = Math.max(0, Math.sin(dotPhase * 3.0)); - const headSquashY = 1 - 0.1 * press; - const headSquashX = 1 + 0.07 * press; - - // ── Head tilt — side-to-side laugh ────────────────────────────────────── - const headTilt = Math.sin((frame / fps) * Math.PI * 2.0) * 9; - - // ── Both arms shake with laughter (opposite phases) ───────────────────── - const leftArmAngle = -15 + Math.sin((frame / fps) * Math.PI * 3.5) * 25; - const rightArmAngle = 15 + Math.sin((frame / fps) * Math.PI * 3.5 + 1.1) * 25; - - const size = Math.min(width, height) * 0.82; - - return ( - - - - - - - - - {/* Body */} - - - - - - - - - - - - - - - - - - {/* Head circle */} - - - - - - - - - - - - - - - - - - {/* Neck shadows */} - - - - - - - - - - - - {/* Left arm */} - - - - - - - - - - - - - - - - - - {/* Right arm */} - - - - - - - - - - - - - - - - - - {/* Cheek highlights */} - - - - - - - - - - - - - {/* Ground shadow */} - - - {/* ── Everything bobs + wobbles ─────────────────────────────────────── */} - - - {/* ── Body ────────────────────────────────────────────────────────── */} - - - {/* ── Left arm — shakes up with laughter ─────────────────────────── */} - - - - - {/* ── Right arm — shakes up with laughter (opposite phase) ─────────── */} - - - - - {/* ── Head group: drift + tilt + squash ───────────────────────────── */} - - - - {/* Neck shadows */} - - - - - - - - {/* Head circle */} - - - {/* Happy ^^ eyes */} - - - - {/* Left cheek */} - - - - - - {/* Right cheek */} - - - - - - {/* Open mouth + tongue */} - - - - - - {/* end head group */} - - - {/* end bob group */} - - - - ); -}; diff --git a/remotion/src/Mascot/mascot-yellow-listening.tsx b/remotion/src/Mascot/mascot-yellow-listening.tsx deleted file mode 100644 index b18df20fe..000000000 --- a/remotion/src/Mascot/mascot-yellow-listening.tsx +++ /dev/null @@ -1,349 +0,0 @@ -import React from "react"; -import { - AbsoluteFill, - Img, - staticFile, - useCurrentFrame, - useVideoConfig, -} from "remotion"; - -type Props = { - accessory?: string; -}; - -/** - * NewMascotListening — idelMascot.svg paths with an attentive "listening" animation. - * • Prominent head tilt toward the raised side (primary listening cue) - * • Right arm curls inward + upward over first ~35 frames, then holds with subtle sway - * • Left arm gentle idle sway - * • Slow body bob (calm, attentive breathing) - * • Slow blink (focused/listening) - * • Cheek warmth pulse - * • Ground shadow - */ -export const NewMascotListening: React.FC = ({ accessory = "none" }) => { - const frame = useCurrentFrame(); - const { fps, width, height } = useVideoConfig(); - const p = (k: string) => `nmls-${k}`; - - // ── Body bob — slow, calm breathing rhythm ───────────────────────────────── - const bob = Math.sin((frame / fps) * Math.PI * 0.9) * 9; - - // ── Head tilt — prominent attentive listening tilt to the right ──────────── - // Head pivot is at the neck (bottom of head circle): cx=493, cy=145+110=255 - const headTiltBase = 11; // degrees clockwise (right) — the "listening lean" - const headTiltOscillate = Math.sin((frame / fps) * Math.PI * 0.35) * 3; - const headTilt = headTiltBase + headTiltOscillate; - - // Subtle head nod (y drift) while tilted — feels like tracking the speaker - const headNodY = Math.sin((frame / fps) * Math.PI * 0.55) * 6; - const headNodX = Math.sin((frame / fps) * Math.PI * 0.28) * 3; - - // ── Left arm — gentle idle sway ──────────────────────────────────────────── - const leftSway = Math.sin((frame / fps) * Math.PI * 1.1) * 6; - - // ── Right arm — held outward throughout, gentle continuous sway ─────────── - // No rise-in: arm stays at the raised listening position every frame so the - // loop never snaps back to the idle position. - const armSway = Math.sin((frame / fps) * Math.PI * 0.75) * 5; - const rightArmAngle = -62 + armSway; - - // ── Blink — slower, focused (attentive listener) ─────────────────────────── - const blinkPeriod = Math.round(fps * 3.5); - const blinkOffset = Math.round(blinkPeriod * 0.3); - const inBlink = (frame + blinkOffset) % blinkPeriod < 5; - const eyeScale = inBlink ? 0.1 : 1; - - // ── Cheek warmth pulse ───────────────────────────────────────────────────── - const cheekOpacity = 0.78 + Math.sin((frame / fps) * Math.PI * 0.85 + 0.6) * 0.09; - - const size = Math.min(width, height) * 0.82; - - // Head tilt pivot: bottom of head circle = neck joint - const headPivotX = 493; - const headPivotY = 255; // cy(145) + r(110) - - return ( - - - - {/* Ground shadow */} - - - - - - {/* Body */} - - - - - - - - - - - - - - - - - - {/* Head circle */} - - - - - - - - - - - - - - - - - - {/* Neck shadows */} - - - - - - - - - - - - {/* Left arm */} - - - - - - - - - - - - - - - - - - {/* Right arm */} - - - - - - - - - - - - - - - - - - {/* Left eye highlights */} - - - - - - - - - - - - {/* Right eye highlights */} - - - - - - - - - - - - - - - - - {/* Cheek highlights */} - - - - - - - - - - - - - {/* Ground shadow — shrinks slightly when body bobs up */} - - - {/* Everything bobs together */} - - - {/* Body */} - - - {/* Left arm — drawn after body so it renders in front */} - - - - - {/* Right arm — swings outward (counter-clockwise) so it's clearly visible */} - - - - - {/* ── Head group — everything tilts together ─────────────────────── */} - - - {/* Neck shadows */} - - - - - - - - {/* Head circle */} - - - {/* Left eye */} - - - {!inBlink && ( - <> - - - - - - - - )} - - - {/* Right eye */} - - - {!inBlink && ( - <> - - - - - - - - - - - )} - - - {/* Left cheek */} - - - - - - - - {/* Right cheek */} - - - - - - - - {/* Mouth — closed attentive smile */} - - - - - {/* end head group */} - - - {/* end bob group */} - - - {accessory !== "none" && ( - - - - )} - - ); -}; diff --git a/remotion/src/Mascot/mascot-yellow-love.tsx b/remotion/src/Mascot/mascot-yellow-love.tsx deleted file mode 100644 index 58036458e..000000000 --- a/remotion/src/Mascot/mascot-yellow-love.tsx +++ /dev/null @@ -1,401 +0,0 @@ -import React from "react"; -import { - AbsoluteFill, - Img, - interpolate, - staticFile, - useCurrentFrame, - useVideoConfig, -} from "remotion"; - -type Props = { - accessory?: string; -}; - -/** - * NewMascotLove — full-loop love pose with heart eyes and floating hearts. - * - * Heart eye paths are from the love-face SVG (730×953 viewBox, head cx=379.614 cy=114). - * They are placed into the 1000×1000 idelMascot coordinate space via - * translate(+113.386, +31) — the exact difference between the two head-circle centres. - */ -export const NewMascotLove: React.FC = ({ accessory = "none" }) => { - const frame = useCurrentFrame(); - const { fps, width, height } = useVideoConfig(); - const p = (k: string) => `nmlv-${k}`; - - const heartProgress = 1; - const normalEyeOpacity = 0; - const heartEyeOpacity = 1; - - // Heart pulse: 2 beats/s, amplitude grows with heartProgress - const heartBeat = 1 + Math.sin((frame / fps) * Math.PI * 4) * 0.09 * heartProgress; - - // ── Body bob — classic idle rhythm ──────────────────────────────────────── - const bob = Math.sin((frame / fps) * Math.PI * 1.2) * 14; - - // ── Head drift + squash ────────────────────────────────────────────────── - const dotPhase = (frame / fps) * Math.PI; - const headDx = Math.sin(dotPhase * 0.7) * 6; - const headDy = Math.sin(dotPhase) * 9; - const press = Math.max(0, Math.sin(dotPhase)); - const headSquashY = 1 - 0.07 * press; - const headSquashX = 1 + 0.04 * press; - - // ── Arms — gentle idle sway (offset phases for natural look) ───────────── - const leftSway = Math.sin((frame / fps) * Math.PI * 1.3) * 7; - const rightSway = Math.sin((frame / fps) * Math.PI * 1.3 + 1.0) * 6; - - // ── Blink — only during normal eye phase ──────────────────────────────── - const inBlink = false; - const eyeScaleNormal = inBlink ? 0.1 : 1; - - // ── Cheek — flushes more during heart phase ────────────────────────────── - const cheekOpacity = - 0.82 + heartProgress * 0.15 + - Math.sin((frame / fps) * Math.PI * 1.1 + 1.0) * 0.06; - - // ── Floating mini hearts ───────────────────────────────────────────────── - // Three hearts loop continuously with a light stagger. - // Returns {x, y (with bob), opacity (gated by heartProgress), scale}. - const floatHeart = (loopFrame: number, startF: number, x: number, baseY: number, sz: number) => { - const localFrame = (loopFrame - startF + 84) % 84; - const prog = interpolate(localFrame, [0, 48], [0, 1], { - extrapolateLeft: "clamp", - extrapolateRight: "clamp", - }); - const y = baseY - 72 * prog + bob; - const op = - interpolate( - localFrame, - [0, 6, 38, 48], - [0, 0.9, 0.9, 0], - { extrapolateLeft: "clamp", extrapolateRight: "clamp" } - ) * heartProgress; - const sc = - sz * - interpolate(localFrame, [0, 8], [0.5, 1], { - extrapolateLeft: "clamp", - extrapolateRight: "clamp", - }); - return { x, y, op, sc }; - }; - - const loopSpan = 84; - const loopFrame = frame % loopSpan; - const hA = floatHeart(loopFrame, 0, 415, 388, 0.9); - const hB = floatHeart(loopFrame, 20, 568, 382, 0.8); - const hC = floatHeart(loopFrame, 40, 490, 358, 1.0); - - const size = Math.min(width, height) * 0.82; - - // Heart-eye SVG → idelMascot coordinate shift - const HX = 113.386; - const HY = 31; - - return ( - - - - {/* Ground shadow */} - - - - - - {/* Body */} - - - - - - - - - - - - - - - - - - {/* Head circle */} - - - - - - - - - - - - - - - - - - {/* Neck shadows */} - - - - - - - - - - - - {/* Left arm */} - - - - - - - - - - - - - - - - - - {/* Right arm */} - - - - - - - - - - - - - - - - - - {/* Normal left eye highlights */} - - - - - - - - - - - - {/* Normal right eye highlights */} - - - - - - - - - - - - - - - - - {/* Cheek highlights */} - - - - - - - - - - - - {/* Pink glow blur (behind heart eyes) */} - - - - - - {/* Ground shadow */} - - - {/* ── Floating mini hearts (bob-synced, gated by heartProgress) ─────── */} - {[hA, hB, hC].map((h, i) => ( - - {/* Simple heart path centred at (0,0) */} - - - ))} - - {/* ── Everything bobs together ──────────────────────────────────────── */} - - - {/* Body */} - - - {/* Left arm — drawn after body so it renders in front */} - - - - - {/* Right arm */} - - - - - {/* ── Head group: drift + squash ──────────────────────────────────── */} - - - {/* Neck shadows */} - - - - - - - - {/* Head circle */} - - - {/* ── Normal round eyes (fade out as heart phase begins) ────────── */} - - {/* Left eye */} - - - - - - - - - - - {/* Right eye */} - - - - - - - - - - - - - - - {/* ── Heart eyes (fade in, pulse with love) ─────────────────────── */} - {/* - Heart eye paths are in "love SVG space" (head cx=379.614 cy=114). - translate(HX, HY) = translate(113.386, 31) maps them to this - 1000×1000 idelMascot space (head cx=493 cy=145). - Pulse scale is applied around each heart's own centre. - Left heart centre in love SVG: ~(312, 433) - Right heart centre in love SVG: ~(470, 432) - */} - - - {/* Soft pink glow behind the hearts */} - - - - {/* Left heart eye — scale around its centre (312,433) in love-SVG space */} - - - - - - - - {/* Right heart eye — scale around its centre (470,432) in love-SVG space */} - - - - - - - - - {/* ── Cheeks (get more flushed during heart phase) ─────────────── */} - - - - - - - - - - - - - - {/* ── Mouth — closed content smile (stays consistent) ───────────── */} - - - - - {/* end head group */} - - - {/* end bob group */} - - - {accessory !== "none" && ( - - - - )} - - ); -}; diff --git a/remotion/src/Mascot/mascot-yellow-pickup.tsx b/remotion/src/Mascot/mascot-yellow-pickup.tsx deleted file mode 100644 index 135bfb537..000000000 --- a/remotion/src/Mascot/mascot-yellow-pickup.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import React from "react"; -import { AbsoluteFill, interpolate, useCurrentFrame, useVideoConfig } from "remotion"; -import { MascotCharacter, mascotSchema, type MascotProps } from "./lib"; - -// Variant: simple bouncy squash-and-stretch in place. -export const yellowMascotPickupSchema = mascotSchema; -export type YellowMascotPickupProps = MascotProps; - -export const YellowMascotPickup: React.FC = (props) => { - const frame = useCurrentFrame(); - const { fps } = useVideoConfig(); - const t = frame / fps; - - // Three bounces with decreasing squash + a small upward hop each peak. - const times = [0, 0.18, 0.36, 0.54, 0.72, 0.90, 1.08, 4.0]; - const sx = interpolate(t, times, [1, 1.18, 1, 1.12, 1, 1.06, 1, 1], { - extrapolateLeft: "clamp", - extrapolateRight: "clamp", - }); - const sy = interpolate(t, times, [1, 0.74, 1, 0.82, 1, 0.91, 1, 1], { - extrapolateLeft: "clamp", - extrapolateRight: "clamp", - }); - // Slight upward hop at each bounce peak (negative = up). Max 40 px. - const ly = interpolate(t, times, [0, 0, -90, 0, -50, 0, -20, 0], { - extrapolateLeft: "clamp", - extrapolateRight: "clamp", - }); - - return ( - - - - ); -}; diff --git a/remotion/src/Mascot/mascot-yellow-sleep.tsx b/remotion/src/Mascot/mascot-yellow-sleep.tsx deleted file mode 100644 index 144f5bd50..000000000 --- a/remotion/src/Mascot/mascot-yellow-sleep.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import React from "react"; -import { z } from "zod"; -import { MascotCharacter, mascotSchema } from "./lib"; - -export const yellowMascotSleepSchema = mascotSchema.extend({ - sleeping: z.boolean().default(true), -}); -export type YellowMascotSleepProps = z.infer; - -// Variant: full-loop sleeping pose with continuous Zzz. -export const YellowMascotSleep: React.FC = (props) => ( - -); diff --git a/remotion/src/Mascot/mascot-yellow-smile-slow.tsx b/remotion/src/Mascot/mascot-yellow-smile-slow.tsx deleted file mode 100644 index 264ccd40d..000000000 --- a/remotion/src/Mascot/mascot-yellow-smile-slow.tsx +++ /dev/null @@ -1,233 +0,0 @@ -import React from "react"; -import { - AbsoluteFill, - useCurrentFrame, - useVideoConfig, -} from "remotion"; - -/** - * NewMascotSyicSmileSlow — syicsmile.svg slow idle animation. - * - * The dark hat/swoosh (#272727) is rendered as a BACK layer (behind the body) - * with its own slow pendulum sway (0.45 Hz, ±5°), pivoting at the hat base. - * Everything else is a gentle idle: 1.0 Hz bob, head drift + squash, - * opposite-phase arm sway. No eye blink (squinting face). - */ -export const NewMascotSyicSmileSlow: React.FC = () => { - const frame = useCurrentFrame(); - const { fps, width, height } = useVideoConfig(); - const p = (k: string) => `nmsss-${k}`; - - const t = frame / fps; - - // Slow body bob - const bob = Math.sin(t * Math.PI * 1.0) * 8; - - // Head drift + squash - const dotPhase = t * Math.PI; - const headDx = Math.sin(dotPhase * 0.7) * 4; - const headDy = Math.sin(dotPhase) * 6; - const press = Math.max(0, Math.sin(dotPhase)); - const headSquashY = 1 - 0.07 * press; - const headSquashX = 1 + 0.04 * press; - - // Gentle opposite-phase arm sway - const leftArmAngle = Math.sin(t * Math.PI * 0.8) * 7; - const rightArmAngle = Math.sin(t * Math.PI * 0.8 + Math.PI) * 7; - - // Hat slow pendulum sway — pivot at base of hat where it meets head (~600, 390) - const hatSway = Math.sin(t * Math.PI * 0.45) * 5; - - const size = Math.min(width, height) * 0.82; - - return ( - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - {/* Ground shadow */} - - - {/* Everything bobs together */} - - - {/* ── BACK LAYER: Black hat/swoosh — slow pendulum sway behind everything ── */} - - - - - {/* Body */} - - - {/* Left arm */} - - - - - {/* Right arm */} - - - - - {/* Head group — drift + squash */} - - - - - - - - - - - - - {/* Left cheek */} - - - - {/* Right cheek */} - - - - {/* Teeth + mouth */} - - - - - - - - - - - - - {/* Left eye (squinting) */} - - - - {/* Right eye (squinting) */} - - - - - - - - - - ); -}; diff --git a/remotion/src/Mascot/mascot-yellow-smile.tsx b/remotion/src/Mascot/mascot-yellow-smile.tsx deleted file mode 100644 index 8cf6857bc..000000000 --- a/remotion/src/Mascot/mascot-yellow-smile.tsx +++ /dev/null @@ -1,231 +0,0 @@ -import React from "react"; -import { - AbsoluteFill, - useCurrentFrame, - useVideoConfig, -} from "remotion"; - -/** - * NewMascotSyicSmile — syicsmile.svg fast energetic animation. - * - * - Rapid body bounce (3 Hz) - * - Fast horizontal wobble (5 Hz) - * - Head shake side-to-side (2 Hz ±8°) - * - Both arms flail out-of-phase (3.5 Hz ±28°) - * - Squinting eyes + big teeth grin stay static (no blink) - */ -export const NewMascotSyicSmile: React.FC = () => { - const frame = useCurrentFrame(); - const { fps, width, height } = useVideoConfig(); - const p = (k: string) => `nmss-${k}`; - - const t = frame / fps; - - // Fast body bounce - const bob = Math.sin(t * Math.PI * 3) * 15; - // Horizontal wobble - const wobble = Math.sin(t * Math.PI * 5) * 6; - // Head tilt side-to-side - const headTilt = Math.sin(t * Math.PI * 2) * 8; - // Head scale bounce (squash on down, stretch on up) - const headBounce = Math.abs(Math.sin(t * Math.PI * 3)); - const headScaleX = 1 + headBounce * 0.04; - const headScaleY = 1 - headBounce * 0.05; - - // Both arms wave fast, out-of-phase - const leftArmAngle = -20 + Math.sin(t * Math.PI * 3.5) * 28; - const rightArmAngle = 20 + Math.sin(t * Math.PI * 3.5 + 1.1) * 28; - - const size = Math.min(width, height) * 0.82; - - return ( - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - {/* Ground shadow */} - - - {/* Everything bobs + wobbles */} - - - {/* Body */} - - - {/* Left arm — fast flail */} - - - - - {/* Right arm — fast flail, out-of-phase */} - - - - - {/* Head group — tilt + bounce scale */} - - - - {/* Neck shadows */} - - - - - - - - {/* Head circle */} - - - {/* Dark swoosh / brow accent */} - - - {/* Left cheek */} - - - - {/* Right cheek */} - - - - {/* Mouth + teeth */} - - - - - - - - - - - - - {/* Left eye (squinting) */} - - - - {/* Right eye (squinting) */} - - - - - - - - - - ); -}; diff --git a/remotion/src/Mascot/mascot-yellow-talking.tsx b/remotion/src/Mascot/mascot-yellow-talking.tsx deleted file mode 100644 index 479741e57..000000000 --- a/remotion/src/Mascot/mascot-yellow-talking.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import React from "react"; -import { MascotCharacter, mascotSchema, type MascotProps } from "./lib"; - -// Variant: idle mascot (steady arms) with lip-sync mouth animation. -export const yellowMascotTalkingSchema = mascotSchema; -export type YellowMascotTalkingProps = MascotProps; - -export const YellowMascotTalking: React.FC = (props) => ( - -); diff --git a/remotion/src/Mascot/mascot-yellow-thinking.tsx b/remotion/src/Mascot/mascot-yellow-thinking.tsx deleted file mode 100644 index c70283e93..000000000 --- a/remotion/src/Mascot/mascot-yellow-thinking.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import React from "react"; -import { z } from "zod"; -import { MascotCharacter, mascotSchema } from "./lib"; - -export const yellowMascotThinkingSchema = mascotSchema.extend({ - thinking: z.boolean().default(true), -}); -export type YellowMascotThinkingProps = z.infer; - -// Variant: full-loop thinking pose. -export const YellowMascotThinking: React.FC = (props) => ( - -); diff --git a/remotion/src/Mascot/mascot-yellow-wave-alt.tsx b/remotion/src/Mascot/mascot-yellow-wave-alt.tsx deleted file mode 100644 index 725f15f04..000000000 --- a/remotion/src/Mascot/mascot-yellow-wave-alt.tsx +++ /dev/null @@ -1,444 +0,0 @@ -import React from "react"; -import { - AbsoluteFill, - Easing, - Img, - interpolate, - staticFile, - useCurrentFrame, - useVideoConfig, -} from "remotion"; - -type Props = { - accessory?: string; -}; - -/** - * Alternate yellow wave animation using the `new-mascot.svg` paths. - * No pop-in: mascot is idle on screen from frame 0. - * • body bob, head drift + squash - * • right arm rises over ~25 frames then waves enthusiastically in a loop - * • left arm gentle idle sway - * • legs rock at hips - * • eyes blink every ~2.6 s - * • closed smile - * • cheek warmth pulse - * • ground shadow - */ -export const NewMascotWave: React.FC = ({ accessory = "none" }) => { - const frame = useCurrentFrame(); - const { fps, width, height } = useVideoConfig(); - const p = (k: string) => `nmw-${k}`; - - const easeInOut = Easing.inOut(Easing.cubic); - - // ── Body bob ───────────────────────────────────────────────────────────────── - const bob = Math.sin((frame / fps) * Math.PI * 1.2) * 16; - - // ── Head drift + squash ─────────────────────────────────────────────────────── - const dotPhase = (frame / fps) * Math.PI * 1.0; - const headDx = Math.sin(dotPhase * 0.7) * 7; - const headDy = Math.sin(dotPhase) * 11; - const press = Math.max(0, Math.sin(dotPhase)); - const headSquashY = 1 - 0.08 * press; - const headSquashX = 1 + 0.05 * press; - - // ── Left arm — gentle idle sway (unchanged) ─────────────────────────────────── - const leftSway = Math.sin((frame / fps) * Math.PI * 1.6) * 7; - - // ── Right arm — rise then wave ──────────────────────────────────────────────── - // Phase 1 (0–25 f): arm smoothly rises from rest to raised "hi" position. - const riseFrames = 25; - const raiseProgress = interpolate(frame, [0, riseFrames], [0, 1], { - extrapolateLeft: "clamp", - extrapolateRight: "clamp", - easing: Easing.out(Easing.cubic), - }); - const raisedAngle = -65; // degrees: brings arm up to ~"hi" position - - // Phase 2 (25 f+): enthusiastic wave oscillation around the raised position. - const wavePeriod = Math.round(fps * 1.3); - const waveFrame = frame >= riseFrames ? (frame - riseFrames) % wavePeriod : 0; - const waveOscillate = - frame >= riseFrames - ? interpolate( - waveFrame, - [ - 0, - wavePeriod * 0.25, - wavePeriod * 0.5, - wavePeriod * 0.75, - wavePeriod, - ], - [0, -22, -2, -24, 0], - { - extrapolateLeft: "clamp", - extrapolateRight: "clamp", - easing: easeInOut, - }, - ) - : 0; - - // Combine: interpolate from idle sway → raised, then add wave on top. - const rightArmAngle = - interpolate(raiseProgress, [0, 1], [0, raisedAngle]) + waveOscillate; - - // ── Legs — subtle hip tilt ──────────────────────────────────────────────────── - const leftLegTilt = Math.sin((frame / fps) * Math.PI * 0.75) * 2.5; - const rightLegTilt = -leftLegTilt; - - // ── Blink every ~2.6 s ──────────────────────────────────────────────────────── - const blinkPeriod = Math.round(fps * 2.6); - const blinkOffset = Math.round(blinkPeriod / 2); - const inBlink = (frame + blinkOffset) % blinkPeriod < 6; - const eyeScale = inBlink ? 0.12 : 1; - - // ── Cheek warmth pulse ──────────────────────────────────────────────────────── - const cheekOpacity = 0.82 + Math.sin((frame / fps) * Math.PI * 1.1 + 1.0) * 0.1; - - const size = Math.min(width, height) * 0.82; - - return ( - - - - {/* Ground shadow */} - - - - - - {/* f0: left leg */} - - - - - - - - - - - - - - - - - - - - - - - {/* f1: right leg */} - - - - - - - - - - - - - - - - - - - - - - - {/* f2: body */} - - - - - - - - - - - - - - - - - - {/* f3: head circle */} - - - - - - - - - - - - - - - - - - {/* f4–f5: neck shadows */} - - - - - - - - - - - - {/* f6: left arm */} - - - - - - - - - - - - - - - - - - {/* f7: right arm */} - - - - - - - - - - - - - - - - - - {/* f8–f9: left eye highlights */} - - - - - - - - - - - - {/* f10–f12: right eye highlights */} - - - - - - - - - - - - - - - - - {/* f13–f14: cheek highlights */} - - - - - - - - - - - - - {/* Ground shadow */} - - - - - {/* Everything bobs together */} - - - {/* Left leg */} - - - - - {/* Right leg */} - - - - - {/* Body */} - - - {/* Left arm — gentle idle sway */} - - - - - {/* Right arm — rises then waves */} - - - - - {/* Neck shadows */} - - - - - - - - {/* Head — drifts + squashes around its center (614.614, 198) */} - - - - - {/* Left eye */} - - - {!inBlink && ( - <> - - - - - - - - )} - - - {/* Right eye */} - - - {!inBlink && ( - <> - - - - - - - - - - - )} - - - {/* Left cheek */} - - - - - - - - {/* Right cheek */} - - - - - - - - {/* Closed smile */} - - - - - - {accessory !== "none" && ( - - - - )} - - ); -}; diff --git a/remotion/src/Mascot/mascot-yellow-wave.tsx b/remotion/src/Mascot/mascot-yellow-wave.tsx deleted file mode 100644 index 9298f4f68..000000000 --- a/remotion/src/Mascot/mascot-yellow-wave.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import React from "react"; -import { MascotCharacter, mascotSchema, type MascotProps } from "./lib"; - -// Variant: waving mascot. -export { mascotSchema }; -export type { MascotProps }; - -export const Mascot: React.FC = (props) => ( - -); diff --git a/remotion/src/Mascot/mascot-yellow-wink.tsx b/remotion/src/Mascot/mascot-yellow-wink.tsx deleted file mode 100644 index 97c2fbed6..000000000 --- a/remotion/src/Mascot/mascot-yellow-wink.tsx +++ /dev/null @@ -1,301 +0,0 @@ -import React from "react"; -import { - AbsoluteFill, - interpolate, - useCurrentFrame, - useVideoConfig, -} from "remotion"; - -/** - * NewMascotWink — full-loop wink animation. - */ -export const NewMascotWink: React.FC = () => { - const frame = useCurrentFrame(); - const { fps, width, height } = useVideoConfig(); - const p = (k: string) => `nmwk-${k}`; - - // ── Relaxed body bob — smooth 1 Hz ────────────────────────────────────── - const bob = Math.sin((frame / fps) * Math.PI * 1.0) * 10; - - // ── Head drift + squash ───────────────────────────────────────────────── - const dotPhase = (frame / fps) * Math.PI; - const headDx = Math.sin(dotPhase * 0.7) * 5; - const headDy = Math.sin(dotPhase) * 7; - const press = Math.max(0, Math.sin(dotPhase)); - const headSquashY = 1 - 0.07 * press; - const headSquashX = 1 + 0.05 * press; - - const openRightEyeOpacity = 0; - const winkEyeOpacity = 1; - - // Slight continuous head tilt with a gentle oscillation. - const headTilt = 4 + Math.sin((frame / fps) * Math.PI * 0.45) * 0.9; - - // ── Left eye blink — loops immediately ────────────────────────────────── - const blinkPeriod = Math.round(fps * 3.5); - const blinkDur = Math.round(fps * 0.14); - const blinkOffset = frame % blinkPeriod; - const leftEyeScaleY = - blinkOffset < blinkDur - ? interpolate( - blinkOffset, - [0, blinkDur * 0.35, blinkDur * 0.65, blinkDur], - [1, 0.06, 0.06, 1], - { extrapolateLeft: "clamp", extrapolateRight: "clamp" } - ) - : 1; - - // ── Right arm — waves for the full loop ───────────────────────────────── - const rightArmAngle = 10 + Math.sin((frame / fps) * Math.PI * 2.5) * 22; - - // ── Left arm — gentle idle sway ───────────────────────────────────────── - const leftArmAngle = Math.sin((frame / fps) * Math.PI * 0.9) * 7; - - const size = Math.min(width, height) * 0.82; - - return ( - - - - - - - - - {/* Body */} - - - - - - - - - - - - - - - - - - {/* Head circle */} - - - - - - - - - - - - - - - - - - {/* Neck shadows */} - - - - - - - - - - - - {/* Left arm */} - - - - - - - - - - - - - - - - - - {/* Right arm */} - - - - - - - - - - - - - - - - - - {/* Left eye highlight (filter5) */} - - - - - - - - - - - - {/* Right open eye highlight (mirrored from left) */} - - - - - - - - - - - - {/* Cheek highlights */} - - - - - - - - - - - - - {/* Ground shadow */} - - - {/* ── Everything bobs ───────────────────────────────────────────────── */} - - - {/* ── Body ────────────────────────────────────────────────────────── */} - - - {/* ── Left arm — gentle idle sway ─────────────────────────────────── */} - - - - - {/* ── Right arm — waves after wink ────────────────────────────────── */} - - - - - {/* ── Head group: drift + tilt + squash ───────────────────────────── */} - - - - {/* Neck shadows */} - - - - - - - - {/* Head circle */} - - - {/* ── Left eye (open) — blinks periodically after wink ── */} - - - - - - - - - - - {/* ── Right eye: open (fades out) → wink (fades in) ── */} - {/* Open right eye — mirror of left eye around x=493 */} - - - - - - - - - - - {/* Wink right eye — stroke arch */} - - - - - {/* Left cheek */} - - - - - - {/* Right cheek */} - - - - - - {/* Smirk mouth */} - - - - - - {/* end head group */} - - - {/* end bob group */} - - - - ); -}; diff --git a/remotion/src/Root.tsx b/remotion/src/Root.tsx deleted file mode 100644 index e573d2c84..000000000 --- a/remotion/src/Root.tsx +++ /dev/null @@ -1,399 +0,0 @@ -import "./index.css"; -import { Composition } from "remotion"; -import { Mascot, mascotSchema } from "./Mascot/mascot-yellow-wave"; -import { - YellowMascotIdle, - yellowMascotIdleSchema, -} from "./Mascot/mascot-yellow-idle"; -import { - YellowMascotPickup, - yellowMascotPickupSchema, -} from "./Mascot/mascot-yellow-pickup"; -import { - YellowMascotTalking, - yellowMascotTalkingSchema, -} from "./Mascot/mascot-yellow-talking"; -import { - YellowMascotSleep, - yellowMascotSleepSchema, -} from "./Mascot/mascot-yellow-sleep"; -import { - YellowMascotThinking, - yellowMascotThinkingSchema, -} from "./Mascot/mascot-yellow-thinking"; -import { NewMascotListening } from "./Mascot/mascot-yellow-listening"; -import { NewMascotLove } from "./Mascot/mascot-yellow-love"; -import { NewMascotCrying } from "./Mascot/mascot-yellow-crying"; -import { NewMascotCelebrate } from "./Mascot/mascot-yellow-celebrate"; -import { NewMascotLaughing } from "./Mascot/mascot-yellow-laughing"; -import { NewMascotWink } from "./Mascot/mascot-yellow-wink"; -import { NewMascotBookReading } from "./Mascot/mascot-yellow-book-reading"; -import { NewMascotHatWithBag } from "./Mascot/mascot-yellow-hat-with-bag"; -import { NewMascotCupHolding } from "./Mascot/mascot-yellow-cup-holding"; -import { NewMascotBobateaHolding } from "./Mascot/mascot-yellow-boba-tea-holding"; -import { NewMascotSyicSmile } from "./Mascot/mascot-yellow-smile"; -import { NewMascotSyicSmileSlow } from "./Mascot/mascot-yellow-smile-slow"; -import { BlackMascotIdle } from "./Mascot/mascot-black-idle"; -import { BlackMascotPickup } from "./Mascot/mascot-black-pickup"; -import { BlackMascotTalking } from "./Mascot/mascot-black-talking"; -import { BlackMascotThinking } from "./Mascot/mascot-black-thinking"; -import { BlackMascotSleep } from "./Mascot/mascot-black-sleep"; -import { BlackMascotLove } from "./Mascot/mascot-black-love"; -import { BlackMascotWave } from "./Mascot/mascot-black-wave"; -import { BlackMascotListening } from "./Mascot/mascot-black-listening"; -import { BlackMascotCrying } from "./Mascot/mascot-black-crying"; -import { BlackMascotWink } from "./Mascot/mascot-black-wink"; -import { BlackMascotCelebrate } from "./Mascot/mascot-black-celebrate"; -import { BlackMascotHatWithBag } from "./Mascot/mascot-black-hat-with-bag"; -import { BlackMascotLaughing } from "./Mascot/mascot-black-laughing"; -import { YellowMascotBumDance } from "./Mascot/YellowMascotBumDance"; - -export const RemotionRoot: React.FC = () => { - return ( - <> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ); -}; diff --git a/remotion/src/index.css b/remotion/src/index.css deleted file mode 100644 index f1d8c73cd..000000000 --- a/remotion/src/index.css +++ /dev/null @@ -1 +0,0 @@ -@import "tailwindcss"; diff --git a/remotion/src/index.ts b/remotion/src/index.ts deleted file mode 100644 index 14dff4c45..000000000 --- a/remotion/src/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -// This is your entry file! Refer to it when you render: -// npx remotion render HelloWorld out/video.mp4 - -import { registerRoot } from "remotion"; -import { RemotionRoot } from "./Root"; - -registerRoot(RemotionRoot); diff --git a/remotion/tsconfig.json b/remotion/tsconfig.json deleted file mode 100644 index 4bdf271b9..000000000 --- a/remotion/tsconfig.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2018", - "module": "commonjs", - "jsx": "react-jsx", - "strict": true, - "noEmit": true, - "lib": ["es2015"], - "esModuleInterop": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true, - "noUnusedLocals": true - }, - "exclude": ["remotion.config.ts"] -} diff --git a/scripts/test-rust-with-mock.sh b/scripts/test-rust-with-mock.sh index c59def1ac..0b0a7c260 100755 --- a/scripts/test-rust-with-mock.sh +++ b/scripts/test-rust-with-mock.sh @@ -49,6 +49,10 @@ export VITE_BACKEND_URL="$MOCK_API_URL" # unless the caller already pinned one explicitly. export RUST_MIN_STACK="${RUST_MIN_STACK:-16777216}" +# The tinyagents harness is the agent engine on every build now (issue #4249); +# the suite exercises it by default. Set OPENHUMAN_AGENT_GRAPH_{TINYAGENTS,CHANNEL, +# SUBAGENT}=0 to force the (being-removed) legacy engine during the transition. + echo "Running Rust tests with BACKEND_URL=$BACKEND_URL and RUST_MIN_STACK=$RUST_MIN_STACK" cd "$REPO_ROOT" # Only source rustup's env if it actually exists. With `set -e`, sourcing a diff --git a/src/core/observability.rs b/src/core/observability.rs index 8edf52279..346645d7c 100644 --- a/src/core/observability.rs +++ b/src/core/observability.rs @@ -3742,26 +3742,6 @@ mod tests { ); } - #[test] - fn context_prefix_too_large_error_display_classifies_as_expected() { - // S3.5.d coupling test: the pre-dispatch actionable error's Display - // string MUST classify as the suppressed ContextWindowExceeded bucket, - // so a wording drift in the user-facing message (which is what gets - // re-raised and re-reported up the stack) fails CI instead of silently - // leaking the event to Sentry. - let err = crate::openhuman::agent::harness::token_budget::ContextPrefixTooLargeError { - prefix_tokens: 10_978, - context_window: 8_192, - max_input_tokens: 7_372, - }; - assert_eq!( - expected_error_kind(&err.to_string()), - Some(ExpectedErrorKind::ContextWindowExceeded), - "ContextPrefixTooLargeError Display must stay coupled to the \ - context-window-exceeded classifier (drift would leak Sentry events)" - ); - } - #[test] fn does_not_classify_unrelated_messages_as_context_window_exceeded() { // Anchors are context-overflow specific. A generic "window" or diff --git a/src/openhuman/agent/bus.rs b/src/openhuman/agent/bus.rs index 8bca5f5db..d591d0553 100644 --- a/src/openhuman/agent/bus.rs +++ b/src/openhuman/agent/bus.rs @@ -2,7 +2,9 @@ //! //! The agent domain publishes one native request handler, `agent.run_turn`, //! which executes a single end-to-end agentic turn (LLM call → tool calls → -//! loop until final text) using the full `run_tool_call_loop` machinery. +//! loop until final text) on the tinyagents harness via +//! [`run_channel_turn_via_graph`](crate::openhuman::agent::harness::run_channel_turn_via_graph) +//! (issue #4249; the legacy `run_tool_call_loop` was removed). //! //! Consumers call it via [`crate::core::event_bus::request_native_global`] //! with an [`AgentTurnRequest`] and receive an [`AgentTurnResponse`]. The @@ -31,7 +33,7 @@ use crate::openhuman::prompt_injection::{ use crate::openhuman::tools::Tool; use super::harness::definition::{AgentDefinitionRegistry, SandboxMode}; -use super::harness::{run_tool_call_loop, with_current_sandbox_mode}; +use super::harness::{run_channel_turn_via_graph, with_current_sandbox_mode}; use crate::openhuman::file_state::with_file_state_agent_id; /// Method name used to dispatch an agentic turn through the native bus. @@ -301,31 +303,26 @@ pub fn register_agent_handlers() { with_file_state_agent_id( file_state_id, with_current_sandbox_mode(sandbox_mode, async { - run_tool_call_loop( - provider.as_ref(), + // Channel/CLI turns run through the tinyagents harness + // (issue #4249); the legacy `run_tool_call_loop` is removed. + // `on_progress` mirrors the harness event stream (tool + // timeline, text deltas, cost footer) — production channel + // dispatch always supplies it and now expects it live. + // `on_delta` (raw Sender) is superseded by + // `on_progress` text deltas, so it's intentionally unused. + let _ = (&provider_name, silent, &channel_name, on_delta); + run_channel_turn_via_graph( + provider.clone(), &mut history, - tools_registry.as_ref(), - &provider_name, + tools_registry.clone(), + extra_tools, + visible_tool_names.as_ref(), &model, temperature, - silent, - &channel_name, - &multimodal, - &multimodal_files, max_tool_iterations, - on_delta, - visible_tool_names.as_ref(), - &extra_tools, + multimodal.clone(), + multimodal_files.clone(), on_progress, - // Bus path runs ad-hoc agent turns without an Agent - // handle, so we pass None — payload summarization is - // wired into the orchestrator session via Agent::turn, - // not the bus dispatcher. - None, - // Use the default (allow-all) tool policy. Custom - // policies can be wired in via AgentTurnRequest when - // per-channel policy configuration is added (#2134). - &crate::openhuman::tools::policy::DefaultToolPolicy, ) .await }), diff --git a/src/openhuman/agent/harness/agent_graph.rs b/src/openhuman/agent/harness/agent_graph.rs new file mode 100644 index 000000000..e9743e272 --- /dev/null +++ b/src/openhuman/agent/harness/agent_graph.rs @@ -0,0 +1,128 @@ +//! Per-agent turn-graph selection (issue #4249). +//! +//! Each built-in agent folder ships a `graph.rs` exporting +//! `pub fn graph() -> AgentGraph`, mirroring the per-agent `prompt.rs::build` +//! hook. The registry loader injects the returned value onto the agent's +//! [`AgentDefinition`] (post-deserialize, exactly like `PromptSource::Dynamic`), +//! and the sub-agent turn chokepoint (`run_typed_mode`) consults it: +//! +//! - [`AgentGraph::Default`] runs the shared default sub-agent turn graph +//! (`subagent_runner::ops::graph::run_subagent_via_graph`). +//! - [`AgentGraph::Custom`] hands the assembled turn to the agent's own graph +//! runner — a bespoke tinyagents graph, thin over +//! `run_turn_via_tinyagents_shared`. +//! +//! Today every built-in agent selects `Default`. The hook is the extension +//! point that lets a specialized agent (orchestrator, researcher, …) define a +//! bespoke graph without branching the shared runner. + +use std::collections::HashSet; +use std::future::Future; +use std::path::PathBuf; +use std::pin::Pin; +use std::sync::Arc; + +use tokio::sync::mpsc::Sender; + +use crate::openhuman::agent::harness::run_queue::RunQueue; +use crate::openhuman::agent::harness::subagent_runner::SubagentRunError; +use crate::openhuman::agent::progress::AgentProgress; +use crate::openhuman::inference::provider::{ChatMessage, Provider}; +use crate::openhuman::tools::{Tool, ToolSpec}; + +/// The assembled inputs for one sub-agent turn, handed to a custom +/// [`AgentGraph::Custom`] runner. +/// +/// Owned (history + tools by value) so the runner can be a boxed `'static` +/// future without borrowing the caller's stack — mirrors the positional +/// arguments the default `run_subagent_via_graph` takes. +pub struct AgentTurnRequest { + pub provider: Arc, + pub model: String, + pub temperature: f64, + /// Full working transcript for the turn (system + prior + this user turn). + pub history: Vec, + pub parent_tools: Arc>>, + pub dynamic_tools: Vec>, + pub specs: Vec, + pub allowed_names: HashSet, + pub max_iterations: usize, + pub run_queue: Option>, + pub on_progress: Option>, + pub agent_id: String, + pub task_id: String, + pub extended_policy: bool, + pub worker_thread_id: Option, + pub workspace_dir: PathBuf, + pub max_output_tokens: u32, + pub model_vision: bool, +} + +/// Token/cost totals a custom runner reports back. Mirrors the runner's internal +/// `AggregatedUsage` without coupling to its (private) type. +#[derive(Debug, Clone, Copy, Default)] +pub struct AgentTurnUsage { + pub input_tokens: u64, + pub output_tokens: u64, + pub cached_input_tokens: u64, + pub charged_amount_usd: f64, +} + +/// The result of a custom turn graph. `history` is the full updated transcript +/// (the runner persists it back and mirrors it to any worker thread). +pub struct AgentTurnResult { + pub history: Vec, + pub output: String, + pub iterations: usize, + pub usage: AgentTurnUsage, + /// Set when an early-exit tool (e.g. `ask_user_clarification`) paused the run. + pub early_exit_tool: Option, + /// `true` when the run stopped at the model-call cap with work still pending. + pub hit_cap: bool, +} + +/// A per-agent custom turn-graph runner: given the assembled [`AgentTurnRequest`], +/// drive a bespoke tinyagents graph and return the [`AgentTurnResult`]. +pub type AgentGraphRunner = + fn( + AgentTurnRequest, + ) -> Pin> + Send>>; + +/// How an agent's turn is driven. Selected per-agent via each folder's +/// `graph.rs::graph()` and injected onto [`AgentDefinition`][super::definition::AgentDefinition]. +#[derive(Clone)] +pub enum AgentGraph { + /// Run the shared default sub-agent turn graph (`run_subagent_via_graph`). + Default, + /// Run this agent's bespoke graph. + Custom(AgentGraphRunner), +} + +impl Default for AgentGraph { + fn default() -> Self { + AgentGraph::Default + } +} + +impl AgentGraph { + /// Build a custom graph selection from a runner fn. Sugar for + /// [`AgentGraph::Custom`] so a folder's `graph.rs` reads + /// `AgentGraph::custom(run)`. + pub fn custom(run: AgentGraphRunner) -> Self { + AgentGraph::Custom(run) + } + + /// `true` when this agent uses the shared default graph. + pub fn is_default(&self) -> bool { + matches!(self, AgentGraph::Default) + } +} + +impl std::fmt::Debug for AgentGraph { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + AgentGraph::Default => f.write_str("Default"), + AgentGraph::Custom(_) => f.write_str("Custom()"), + } + } +} diff --git a/src/openhuman/agent/harness/archivist/recap.rs b/src/openhuman/agent/harness/archivist/recap.rs index 05704e708..2ad95b3ec 100644 --- a/src/openhuman/agent/harness/archivist/recap.rs +++ b/src/openhuman/agent/harness/archivist/recap.rs @@ -272,7 +272,7 @@ impl ArchivistHook { tracing::debug!( "[archivist] rolling_segment_recap: only heuristic bookend stub \ available (no real LLM recap) session={session_id} segment={} — \ - returning None so compaction falls back to ProviderSummarizer", + returning None", open_segment.segment_id ); return None; diff --git a/src/openhuman/agent/harness/bughunt_tests.rs b/src/openhuman/agent/harness/bughunt_tests.rs deleted file mode 100644 index 9c287bbf0..000000000 --- a/src/openhuman/agent/harness/bughunt_tests.rs +++ /dev/null @@ -1,579 +0,0 @@ -//! Targeted bug-hunt tests for the agent harness + tool dispatch. -//! -//! These pair the [`super::test_support::KeywordScriptedProvider`] with -//! tightly-scoped tools to probe corner cases that aren't covered by -//! the broader behavioural suite. Each test documents the behaviour -//! observed, and any tests prefixed `documents_` describe a quirk -//! worth flagging in code review (silent data loss, surprising -//! precedence rules, etc.) rather than asserting correctness. - -use super::test_support::{KeywordRule, KeywordScriptedProvider, ScriptedToolCall}; -use super::tool_loop::run_tool_call_loop; -use crate::openhuman::inference::provider::{ChatMessage, ChatResponse, ToolCall}; -use crate::openhuman::tools::traits::{Tool, ToolResult}; -use async_trait::async_trait; -use parking_lot::Mutex; -use serde_json::json; -use std::sync::Arc; - -fn mm() -> crate::openhuman::config::MultimodalConfig { - crate::openhuman::config::MultimodalConfig::default() -} - -fn mff() -> crate::openhuman::config::MultimodalFileConfig { - crate::openhuman::config::MultimodalFileConfig::default() -} - -struct ArgsCapturingTool { - name_str: String, - captured: Arc>>, - output: String, -} - -impl ArgsCapturingTool { - fn new(name: &str, output: &str) -> (Self, Arc>>) { - let captured = Arc::new(Mutex::new(Vec::new())); - ( - Self { - name_str: name.to_string(), - captured: captured.clone(), - output: output.to_string(), - }, - captured, - ) - } -} - -#[async_trait] -impl Tool for ArgsCapturingTool { - fn name(&self) -> &str { - &self.name_str - } - fn description(&self) -> &str { - "captures args" - } - fn parameters_schema(&self) -> serde_json::Value { - json!({"type":"object","additionalProperties":true}) - } - async fn execute(&self, args: serde_json::Value) -> anyhow::Result { - self.captured.lock().push(args); - Ok(ToolResult::success(self.output.clone())) - } -} - -// ── 1. Native tool call with a JSON-encoded string of args ──────── -// -// Real OpenAI/Anthropic providers send `arguments` as a *string* -// containing JSON. The harness must transparently decode it before -// passing to the tool. - -#[tokio::test] -async fn native_tool_call_decodes_json_encoded_arguments_string() { - let provider = - KeywordScriptedProvider::new(vec![KeywordRule::final_reply("captured-ok", "done")]) - .with_native_tools(true); - - // Forced first turn: native tool_call with arguments as a STRING. - provider.push_forced_response(ChatResponse { - text: None, - tool_calls: vec![ToolCall { - id: "c1".into(), - name: "captured".into(), - arguments: "{\"city\":\"Berlin\",\"n\":3}".to_string(), - extra_content: None, - }], - usage: None, - reasoning_content: None, - }); - - let (tool, captured) = ArgsCapturingTool::new("captured", "captured-ok"); - let tools: Vec> = vec![Box::new(tool)]; - let mut history = vec![ChatMessage::user("anything")]; - - let out = run_tool_call_loop( - &provider, - &mut history, - &tools, - "mock", - "m", - 0.0, - true, - "channel", - &mm(), - &mff(), - 3, - None, - None, - &[], - None, - None, - &crate::openhuman::tools::policy::DefaultToolPolicy, - ) - .await - .unwrap(); - - assert_eq!(out, "done"); - let args = captured.lock(); - assert_eq!(args.len(), 1); - assert_eq!(args[0]["city"], "Berlin"); - assert_eq!(args[0]["n"], 3); -} - -// ── 2. SILENT FAILURE: non-JSON args string is replaced with `{}` ── -// -// `parse_arguments_value` calls `serde_json::from_str` on the string -// payload and silently falls back to `{}` on parse failure. This -// means: if a model emits `arguments: "world"` (not valid JSON), the -// tool sees `{}` — the user's intent is silently dropped and there's -// no signal to the LLM that anything went wrong. -// -// This test documents the behaviour so future refactors don't -// "accidentally" fix it without considering downstream impact, and -// flags the behaviour for follow-up. - -#[tokio::test] -async fn documents_silent_drop_of_non_json_arguments_string() { - let provider = - KeywordScriptedProvider::new(vec![KeywordRule::final_reply("captured-ok", "done")]) - .with_native_tools(true); - - provider.push_forced_response(ChatResponse { - text: None, - tool_calls: vec![ToolCall { - id: "c1".into(), - name: "captured".into(), - // Not valid JSON — the model "meant" a plain string. - arguments: "world".to_string(), - extra_content: None, - }], - usage: None, - reasoning_content: None, - }); - - let (tool, captured) = ArgsCapturingTool::new("captured", "captured-ok"); - let tools: Vec> = vec![Box::new(tool)]; - let mut history = vec![ChatMessage::user("hi")]; - - run_tool_call_loop( - &provider, - &mut history, - &tools, - "mock", - "m", - 0.0, - true, - "channel", - &mm(), - &mff(), - 3, - None, - None, - &[], - None, - None, - &crate::openhuman::tools::policy::DefaultToolPolicy, - ) - .await - .unwrap(); - - let args = captured.lock(); - assert_eq!(args.len(), 1); - // BUG-CANDIDATE: the LLM's intent ("world") is silently dropped. - // The tool receives an empty object with no indication the args - // were unparseable. A more defensive design would surface a - // structured error back to the model instead. - assert_eq!(args[0], json!({})); -} - -// ── 3. Parallel tool calls in a single iteration ────────────────── -// -// The model may emit multiple `` blocks at once. They -// should all execute in order, each result threaded into history. - -#[tokio::test] -async fn parallel_tool_calls_in_single_iteration_all_execute() { - let provider = - KeywordScriptedProvider::new(vec![KeywordRule::final_reply("tool_b-ok", "all done")]); - - // Both tool calls share one assistant turn (XML path). - provider.push_forced_response(ChatResponse { - text: Some( - "{\"name\":\"tool_a\",\"arguments\":{\"k\":1}}\n\ - {\"name\":\"tool_b\",\"arguments\":{\"k\":2}}" - .into(), - ), - tool_calls: vec![], - usage: None, - reasoning_content: None, - }); - - let (a, a_calls) = ArgsCapturingTool::new("tool_a", "tool_a-ok"); - let (b, b_calls) = ArgsCapturingTool::new("tool_b", "tool_b-ok"); - let tools: Vec> = vec![Box::new(a), Box::new(b)]; - let mut history = vec![ChatMessage::user("do both")]; - - let out = run_tool_call_loop( - &provider, - &mut history, - &tools, - "mock", - "m", - 0.0, - true, - "channel", - &mm(), - &mff(), - 5, - None, - None, - &[], - None, - None, - &crate::openhuman::tools::policy::DefaultToolPolicy, - ) - .await - .unwrap(); - - assert_eq!(out, "all done"); - assert_eq!(a_calls.lock().len(), 1); - assert_eq!(b_calls.lock().len(), 1); - assert_eq!(a_calls.lock()[0]["k"], 1); - assert_eq!(b_calls.lock()[0]["k"], 2); -} - -// ── 4. Same-named tools: first match in registry wins ───────────── - -#[tokio::test] -async fn same_named_tool_in_registry_first_match_wins() { - let provider = KeywordScriptedProvider::new(vec![ - KeywordRule::tool_call("go", ScriptedToolCall::new("dupe", json!({}))), - KeywordRule::final_reply("first-output", "got first"), - ]); - - let (first, first_calls) = ArgsCapturingTool::new("dupe", "first-output"); - let (second, second_calls) = ArgsCapturingTool::new("dupe", "second-output"); - let tools: Vec> = vec![Box::new(first), Box::new(second)]; - let mut history = vec![ChatMessage::user("go ahead")]; - - let out = run_tool_call_loop( - &provider, - &mut history, - &tools, - "mock", - "m", - 0.0, - true, - "channel", - &mm(), - &mff(), - 5, - None, - None, - &[], - None, - None, - &crate::openhuman::tools::policy::DefaultToolPolicy, - ) - .await - .unwrap(); - - assert_eq!(out, "got first"); - assert_eq!(first_calls.lock().len(), 1); - assert_eq!(second_calls.lock().len(), 0); -} - -// ── 5. Markdown-fenced tool call (```tool_call ... ```) ─────────── -// -// Some OpenRouter-mediated models emit fenced markdown blocks -// instead of XML tags. `parse_tool_calls` is supposed to handle this. - -#[tokio::test] -async fn markdown_fenced_tool_call_block_is_parsed() { - let provider = - KeywordScriptedProvider::new(vec![KeywordRule::final_reply("tool_a-ok", "ok done")]); - - provider.push_forced_response(ChatResponse { - text: Some( - "Here's the call:\n\ - ```tool_call\n\ - {\"name\":\"tool_a\",\"arguments\":{\"x\":42}}\n\ - ```" - .into(), - ), - tool_calls: vec![], - usage: None, - reasoning_content: None, - }); - - let (a, a_calls) = ArgsCapturingTool::new("tool_a", "tool_a-ok"); - let tools: Vec> = vec![Box::new(a)]; - let mut history = vec![ChatMessage::user("anything")]; - - let out = run_tool_call_loop( - &provider, - &mut history, - &tools, - "mock", - "m", - 0.0, - true, - "channel", - &mm(), - &mff(), - 5, - None, - None, - &[], - None, - None, - &crate::openhuman::tools::policy::DefaultToolPolicy, - ) - .await - .unwrap(); - - assert_eq!(out, "ok done"); - assert_eq!(a_calls.lock().len(), 1); - assert_eq!(a_calls.lock()[0]["x"], 42); -} - -// ── 6. Native vs prompt-guided precedence ───────────────────────── -// -// When a response carries BOTH native `tool_calls` *and* an XML -// `` block in the text, the native calls are authoritative -// and the XML must NOT also fire (else the same logical call could -// execute twice). - -#[tokio::test] -async fn native_tool_calls_take_precedence_over_xml_in_text() { - let provider = - KeywordScriptedProvider::new(vec![KeywordRule::final_reply("tool_a-ok", "done")]) - .with_native_tools(true); - - provider.push_forced_response(ChatResponse { - text: Some("{\"name\":\"tool_a\",\"arguments\":{}}".into()), - tool_calls: vec![ToolCall { - id: "c1".into(), - name: "tool_a".into(), - arguments: "{\"src\":\"native\"}".into(), - extra_content: None, - }], - usage: None, - reasoning_content: None, - }); - - let (a, a_calls) = ArgsCapturingTool::new("tool_a", "tool_a-ok"); - let tools: Vec> = vec![Box::new(a)]; - let mut history = vec![ChatMessage::user("call it")]; - - run_tool_call_loop( - &provider, - &mut history, - &tools, - "mock", - "m", - 0.0, - true, - "channel", - &mm(), - &mff(), - 5, - None, - None, - &[], - None, - None, - &crate::openhuman::tools::policy::DefaultToolPolicy, - ) - .await - .unwrap(); - - // Tool ran exactly once, using the native args (not the XML block). - assert_eq!(a_calls.lock().len(), 1); - assert_eq!(a_calls.lock()[0]["src"], "native"); -} - -// ── 7. Big tool output: per-tool cap truncation ─────────────────── - -struct CappedBigTool; - -#[async_trait] -impl Tool for CappedBigTool { - fn name(&self) -> &str { - "cap_big" - } - fn description(&self) -> &str { - "emits a big payload but caps it" - } - fn parameters_schema(&self) -> serde_json::Value { - json!({"type":"object"}) - } - fn max_result_size_chars(&self) -> Option { - Some(50) - } - async fn execute(&self, _args: serde_json::Value) -> anyhow::Result { - Ok(ToolResult::success("X".repeat(500))) - } -} - -#[tokio::test] -async fn per_tool_max_result_size_caps_history_payload() { - let provider = KeywordScriptedProvider::new(vec![ - KeywordRule::tool_call("go", ScriptedToolCall::new("cap_big", json!({}))), - KeywordRule::final_reply("truncated by tool cap", "ok"), - ]); - - let tools: Vec> = vec![Box::new(CappedBigTool)]; - let mut history = vec![ChatMessage::user("go big")]; - - run_tool_call_loop( - &provider, - &mut history, - &tools, - "mock", - "m", - 0.0, - true, - "channel", - &mm(), - &mff(), - 5, - None, - None, - &[], - None, - None, - &crate::openhuman::tools::policy::DefaultToolPolicy, - ) - .await - .unwrap(); - - let tool_results = history - .iter() - .find(|msg| msg.role == "user" && msg.content.contains("[Tool results]")) - .expect("tool results should land in history"); - assert!( - tool_results.content.contains("truncated by tool cap"), - "cap marker missing: {}", - tool_results.content - ); - assert!( - tool_results.content.len() < 500, - "raw 500-char body must not flow through (got {} chars)", - tool_results.content.len() - ); -} - -// ── 8. Empty assistant response with no tool calls terminates loop - -#[tokio::test] -async fn empty_response_with_no_tool_calls_terminates_with_empty_text() { - let provider = KeywordScriptedProvider::new(vec![]); - provider.push_forced_response(ChatResponse { - text: Some(String::new()), - tool_calls: vec![], - usage: None, - reasoning_content: None, - }); - - let tools: Vec> = vec![]; - let mut history = vec![ChatMessage::user("hi")]; - - let out = run_tool_call_loop( - &provider, - &mut history, - &tools, - "mock", - "m", - 0.0, - true, - "channel", - &mm(), - &mff(), - 5, - None, - None, - &[], - None, - None, - &crate::openhuman::tools::policy::DefaultToolPolicy, - ) - .await - .unwrap(); - - assert!(out.is_empty()); - // Loop must still record the (empty) assistant turn. - assert!(history.iter().any(|m| m.role == "assistant")); -} - -// ── 9. Progress sink receives ordered turn lifecycle events ─────── - -#[tokio::test] -async fn progress_sink_emits_lifecycle_events_in_order() { - use crate::openhuman::agent::progress::AgentProgress; - - let provider = KeywordScriptedProvider::new(vec![ - KeywordRule::tool_call("go", ScriptedToolCall::new("p_tool", json!({}))), - KeywordRule::final_reply("p_tool-ok", "all done"), - ]); - - let (tool, _) = ArgsCapturingTool::new("p_tool", "p_tool-ok"); - let tools: Vec> = vec![Box::new(tool)]; - - let (tx, mut rx) = tokio::sync::mpsc::channel::(32); - - let mut history = vec![ChatMessage::user("go go")]; - run_tool_call_loop( - &provider, - &mut history, - &tools, - "mock", - "m", - 0.0, - true, - "channel", - &mm(), - &mff(), - 5, - None, - None, - &[], - Some(tx), - None, - &crate::openhuman::tools::policy::DefaultToolPolicy, - ) - .await - .unwrap(); - - let mut events = Vec::new(); - while let Ok(e) = rx.try_recv() { - events.push(e); - } - - // Must see TurnStarted, at least 2 IterationStarted, ToolCallStarted, - // ToolCallCompleted, and TurnCompleted. - let kinds: Vec<&'static str> = events - .iter() - .map(|e| match e { - AgentProgress::TurnStarted => "TurnStarted", - AgentProgress::IterationStarted { .. } => "IterationStarted", - AgentProgress::ToolCallStarted { .. } => "ToolCallStarted", - AgentProgress::ToolCallCompleted { .. } => "ToolCallCompleted", - AgentProgress::TurnCompleted { .. } => "TurnCompleted", - _ => "Other", - }) - .collect(); - - assert_eq!(kinds.first().copied(), Some("TurnStarted")); - assert_eq!(kinds.last().copied(), Some("TurnCompleted")); - assert!(kinds.contains(&"IterationStarted")); - assert!(kinds.contains(&"ToolCallStarted")); - assert!(kinds.contains(&"ToolCallCompleted")); - - // ToolCallStarted must precede its matching ToolCallCompleted. - let started = kinds.iter().position(|k| *k == "ToolCallStarted").unwrap(); - let completed = kinds - .iter() - .position(|k| *k == "ToolCallCompleted") - .unwrap(); - assert!(started < completed); -} diff --git a/src/openhuman/agent/harness/builtin_definitions.rs b/src/openhuman/agent/harness/builtin_definitions.rs index 4e6992afd..da7eee409 100644 --- a/src/openhuman/agent/harness/builtin_definitions.rs +++ b/src/openhuman/agent/harness/builtin_definitions.rs @@ -83,6 +83,7 @@ pub(crate) fn test_main_def() -> AgentDefinition { delegate_name: None, agent_tier: AgentTier::Chat, source: DefinitionSource::Builtin, + graph: Default::default(), } } @@ -126,6 +127,7 @@ pub(crate) fn test_inherit_echo_def() -> AgentDefinition { delegate_name: None, agent_tier: crate::openhuman::agent::harness::definition::AgentTier::Worker, source: DefinitionSource::Builtin, + graph: Default::default(), } } @@ -165,6 +167,7 @@ pub(crate) fn test_inherit_parallel_worker_def() -> AgentDefinition { delegate_name: None, agent_tier: crate::openhuman::agent::harness::definition::AgentTier::Worker, source: DefinitionSource::Builtin, + graph: Default::default(), } } diff --git a/src/openhuman/agent/harness/definition.rs b/src/openhuman/agent/harness/definition.rs index bd6dcdb71..6c2399131 100644 --- a/src/openhuman/agent/harness/definition.rs +++ b/src/openhuman/agent/harness/definition.rs @@ -27,6 +27,13 @@ use std::path::PathBuf; use crate::openhuman::tokenjuice::AgentTokenjuiceCompression; +/// Iteration ceiling for an [`IterationPolicy::Extended`] agent — the higher +/// bound a long-running agent (orchestrator, deep research) is allowed to reach +/// before the harness stops it. Lives here, the sole consumer, since the legacy +/// `tool_loop` that originally defined it was removed in the tinyagents +/// migration (issue #4249). +pub const EXTENDED_MAX_TOOL_ITERATIONS: usize = 50; + /// Iteration-cap policy for a sub-agent. /// /// Controls how the harness enforces [`AgentDefinition::max_iterations`]: @@ -36,7 +43,7 @@ use crate::openhuman::tokenjuice::AgentTokenjuiceCompression; /// the cap signals a likely loop. /// * **Extended** — the per-agent `max_iterations` is replaced at runtime /// by a higher harness-wide constant -/// ([`EXTENDED_MAX_TOOL_ITERATIONS`](super::tool_loop::EXTENDED_MAX_TOOL_ITERATIONS)) +/// ([`EXTENDED_MAX_TOOL_ITERATIONS`]) /// so the agent can complete realistic multi-tool workflows. The /// repeated-failure circuit breaker and cost budget still apply. The /// UI omits the denominator ("step N" instead of "turn N/M") to avoid @@ -276,6 +283,15 @@ pub struct AgentDefinition { /// Tracks where the definition was loaded from (Builtin vs. File). #[serde(skip)] pub source: DefinitionSource, + + // ── turn graph ────────────────────────────────────────────────────── + /// How this agent's turn is driven (issue #4249). Injected post-load from + /// the agent folder's `graph.rs::graph()` (mirrors how + /// [`PromptSource::Dynamic`] is injected from `prompt.rs::build`); TOML- + /// authored agents cannot set it, so it is `#[serde(skip)]` and defaults to + /// [`AgentGraph::Default`] (the shared default turn graph). + #[serde(skip, default)] + pub graph: super::agent_graph::AgentGraph, } // ───────────────────────────────────────────────────────────────────────────── @@ -457,13 +473,11 @@ impl AgentDefinition { /// /// * `Strict` → `self.max_iterations` unchanged. /// * `Extended` → the higher of `self.max_iterations` and the - /// harness-wide [`EXTENDED_MAX_TOOL_ITERATIONS`](super::tool_loop::EXTENDED_MAX_TOOL_ITERATIONS). + /// harness-wide [`EXTENDED_MAX_TOOL_ITERATIONS`]. pub fn effective_max_iterations(&self) -> usize { match self.iteration_policy { IterationPolicy::Strict => self.max_iterations, - IterationPolicy::Extended => self - .max_iterations - .max(super::tool_loop::EXTENDED_MAX_TOOL_ITERATIONS), + IterationPolicy::Extended => self.max_iterations.max(EXTENDED_MAX_TOOL_ITERATIONS), } } diff --git a/src/openhuman/agent/harness/definition_tests.rs b/src/openhuman/agent/harness/definition_tests.rs index 9a73d876b..9bf9c3cd9 100644 --- a/src/openhuman/agent/harness/definition_tests.rs +++ b/src/openhuman/agent/harness/definition_tests.rs @@ -31,6 +31,7 @@ fn make_def(id: &str) -> AgentDefinition { delegate_name: None, agent_tier: crate::openhuman::agent::harness::definition::AgentTier::Worker, source: DefinitionSource::Builtin, + graph: Default::default(), } } @@ -237,7 +238,7 @@ fn extended_policy_raises_cap_to_at_least_extended_constant() { def.iteration_policy = IterationPolicy::Extended; assert_eq!( def.effective_max_iterations(), - super::super::tool_loop::EXTENDED_MAX_TOOL_ITERATIONS + super::EXTENDED_MAX_TOOL_ITERATIONS ); assert!(def.effective_max_iterations() > def.max_iterations); } @@ -268,7 +269,7 @@ iteration_policy = "extended" assert_eq!(def.iteration_policy, IterationPolicy::Extended); assert_eq!( def.effective_max_iterations(), - super::super::tool_loop::EXTENDED_MAX_TOOL_ITERATIONS + super::EXTENDED_MAX_TOOL_ITERATIONS ); } diff --git a/src/openhuman/agent/harness/engine/checkpoint.rs b/src/openhuman/agent/harness/engine/checkpoint.rs index eb84b6c35..964dd1748 100644 --- a/src/openhuman/agent/harness/engine/checkpoint.rs +++ b/src/openhuman/agent/harness/engine/checkpoint.rs @@ -32,20 +32,3 @@ pub(crate) trait CheckpointStrategy: Send + Sync { /// `tool → outcome` summary of the run so far. async fn on_max_iter(&self, digest: &str, max_iterations: usize) -> Result; } - -/// Surface the cap as the typed [`AgentError::MaxIterationsExceeded`], boxed -/// through `anyhow::Error`, so downstream wrappers — notably -/// `Agent::run_single` — can downcast and suppress Sentry emission for this -/// deterministic agent-state outcome (OPENHUMAN-TAURI-99 / -98). -pub(crate) struct ErrorCheckpoint; - -#[async_trait] -impl CheckpointStrategy for ErrorCheckpoint { - async fn on_max_iter(&self, _digest: &str, max_iterations: usize) -> Result { - Err(anyhow::Error::new( - crate::openhuman::agent::error::AgentError::MaxIterationsExceeded { - max: max_iterations, - }, - )) - } -} diff --git a/src/openhuman/agent/harness/engine/core.rs b/src/openhuman/agent/harness/engine/core.rs deleted file mode 100644 index 8764429a8..000000000 --- a/src/openhuman/agent/harness/engine/core.rs +++ /dev/null @@ -1,1174 +0,0 @@ -//! The unified turn loop. -//! -//! [`run_turn_engine`] is the single agentic loop the harness runs: announce the -//! turn, then per iteration run the stop-hook + context guards, send the -//! provider request (streaming deltas when the [`ProgressReporter`] supplies a -//! sink), parse the response, either return the final text or execute every -//! requested tool through the [`ToolSource`] and loop again — bailing early via -//! the shared repeated-failure circuit breaker, or handing the iteration cap to -//! the [`CheckpointStrategy`]. -//! -//! Everything that varies per caller lives behind a seam: [`ToolSource`] (tool -//! advertisement + per-call execution), [`ProgressReporter`] (Turn* vs -//! Subagent* events + streaming), [`TurnObserver`] (context management, -//! transcript persistence, worker-thread mirroring) and [`CheckpointStrategy`] -//! (error vs summarize on cap). The universal concerns — stop hooks, the -//! context guard, token-budget trimming, native/text parsing and the circuit -//! breaker — stay inline. - -use anyhow::Result; -use std::fmt::Write as _; -use std::io::Write as _; -use std::sync::Arc; - -use crate::openhuman::agent::cost::TurnCost; -use crate::openhuman::agent::multimodal; -use crate::openhuman::agent::stop_hooks::{current_stop_hooks, StopDecision, TurnState}; -use crate::openhuman::context::guard::{ContextCheckResult, ContextGuard}; -use crate::openhuman::context::{summarize_chat_history, EngineAutocompact}; -use crate::openhuman::inference::provider::{ - ChatMessage, ChatRequest, Provider, ProviderCapabilityError, ToolCall, UsageInfo, -}; - -use super::super::parse::build_native_assistant_history; -use super::super::run_queue::RunQueue; -use super::super::session::transcript::{self, MessageUsage, TurnUsage}; -use super::super::token_budget::trim_chat_messages_to_budget; -use super::super::tool_loop::{ - RepeatCallGuard, RepeatFailureGuard, RepeatOutputGuard, STREAM_CHUNK_MIN_CHARS, -}; -use super::checkpoint::CheckpointStrategy; -use super::parser::ResponseParser; -use super::progress::ProgressReporter; -use super::state::TurnObserver; -use super::tool_source::ToolSource; - -/// Why a turn ended. Both `Halted` and `Cap` produce a summary `text` but mean -/// the task did NOT reach its goal — stateful callers (notably the sub-agent -/// runner) use this to tell a genuine finish apart from a stuck stop, instead of -/// having to sniff the summary prose. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum TurnStop { - /// The model returned a final response with no tool calls — a real finish. - Final, - /// A circuit breaker halted the run (repeated identical call / repeated - /// output / repeated failure). `text` is the no-progress halt summary. - Halted, - /// The iteration cap was reached; `text` is the checkpoint summary. - Cap, - /// An early-exit tool (e.g. `ask_user_clarification`) requested the stop; - /// the tool name is in [`TurnEngineOutcome::early_exit_tool`]. - EarlyExit, -} - -fn transcript_turn_usage( - provider: &str, - model: &str, - usage: Option<&UsageInfo>, - reasoning_content: Option<&str>, - tool_calls: &[ToolCall], - iteration: usize, -) -> Option { - let usage = usage?; - Some(TurnUsage { - provider: provider.to_string(), - model: model.to_string(), - usage: MessageUsage { - input: usage.input_tokens, - output: usage.output_tokens, - cached_input: usage.cached_input_tokens, - context_window: usage.context_window, - cost_usd: usage.charged_amount_usd, - }, - ts: chrono::Utc::now().to_rfc3339(), - reasoning_content: reasoning_content - .map(str::trim) - .filter(|s| !s.is_empty()) - .map(ToString::to_string), - tool_calls: tool_calls.to_vec(), - iteration: (iteration + 1) as u32, - }) -} - -/// What a completed turn yields. `text` is the final assistant text (or the -/// circuit-breaker / checkpoint summary); `iterations` and `cost` let stateful -/// callers attribute the run. -pub(crate) struct TurnEngineOutcome { - pub text: String, - pub iterations: u32, - pub cost: TurnCost, - /// Why the turn ended. `Agent::turn` keys its checkpoint-only - /// history/transcript handling off `Cap`; the sub-agent runner maps - /// `Halted`/`Cap` to an incomplete status. - pub stop: TurnStop, - /// When set, the turn exited early because a specific tool requested - /// it (e.g. `ask_user_clarification` inside a sub-agent). The tool - /// result is in `text`. Callers use this to propagate pause semantics - /// without modifying the checkpoint strategy. - pub early_exit_tool: Option, -} - -/// Truncate a digest entry's body so a huge tool result can't blow up the -/// checkpoint summary. Mirrors the subagent's previous `truncate_with_ellipsis`. -fn truncate_with_ellipsis(s: &str, max: usize) -> String { - if s.chars().count() <= max { - return s.to_string(); - } - let head: String = s.chars().take(max).collect(); - format!("{head}…") -} - -/// Resolve whether the current turn's model accepts image input. -/// -/// The per-model/tier flag (`model_vision`, set at session build from -/// `oh_tier_supports_vision` + the user's `model_registry.vision`) is -/// authoritative. The provider-level `supports_vision()` is too coarse on the -/// managed backend — it advertises `vision: true` for the backend as a whole, -/// which would wrongly rehydrate images for non-vision tiers (e.g. the `chat-v1` -/// orchestrator) and 400 on `image_url`. So it is only a fallback when no -/// per-model scope is active (CLI / direct invocation / tests). -fn turn_accepts_images(model_vision: Option, provider_supports_vision: bool) -> bool { - model_vision.unwrap_or(provider_supports_vision) -} - -/// Run the agent loop over `history` using `tools`. `max_iterations` must be -/// pre-normalized (callers map `0` to a sane default). See the module docs for -/// the per-iteration flow. -#[allow(clippy::too_many_arguments)] -pub(crate) async fn run_turn_engine( - provider: &dyn Provider, - history: &mut Vec, - tools: &mut dyn ToolSource, - progress: &dyn ProgressReporter, - observer: &mut dyn TurnObserver, - checkpoint: &dyn CheckpointStrategy, - parser: &dyn ResponseParser, - provider_name: &str, - model: &str, - temperature: f64, - silent: bool, - multimodal_config: &crate::openhuman::config::MultimodalConfig, - multimodal_file_config: &crate::openhuman::config::MultimodalFileConfig, - max_iterations: usize, - max_output_tokens: u32, - on_delta: Option>, - early_exit_tool_names: &[&str], - run_queue: Option>, - // When `Some`, the engine summarizes `history` in place once the context - // guard reports the window is filling (the soft compaction threshold). - // The main `Agent` path leaves this `None` — it compacts through its typed - // `ContextManager` in `observer.before_dispatch` instead — so only the - // sub-agent loop (which has no `ContextManager`) opts in. - autocompact: Option<&EngineAutocompact>, -) -> Result { - // Resolve the model's context window once per turn. Local providers (e.g. - // LM Studio) report their *runtime-loaded* window here, which can be far - // smaller than the model's trained maximum in the static table — trimming - // to the max would overflow the loaded `n_ctx` (#3550 / TAURI-RUST-6V0). - // - // For local providers this is now always `Some` (a conservative floor backs - // up any missing profile default — see - // `context_window_for_model_with_local_fallback`), so trimming always - // engages for them. `None` therefore means a *cloud* provider with an - // unknown model: those windows are large, so skipping the pre-dispatch trim - // is the correct conservative choice (a tiny floor would needlessly truncate - // a legitimate large-context request). - let effective_context_window = provider.effective_context_window(model).await; - match effective_context_window { - Some(context_window) => tracing::debug!( - provider = provider_name, - model, - context_window, - "[agent_loop] effective context window resolved" - ), - None => tracing::debug!( - provider = provider_name, - model, - "[agent_loop] effective context window unavailable (cloud unknown model); pre-dispatch trimming skipped this turn" - ), - } - // Model-aware locality: a router whose *default* is cloud may still route - // THIS model to a local provider, so gate the pre-dispatch un-evictable - // prefix abort on the routed provider, not the default (#3550 / - // TAURI-RUST-6V0; PR #3771 review). - let model_is_local = provider.is_local_provider_for_model(model); - // Authoritative runtime-loaded window for the hard abort. `effective_context - // _window` above may be a *guess* (profile default / conservative floor) for - // a local model that exposes no loaded window — safe to TRIM against, but - // aborting with "reload with a larger context length" against a guess would - // wrongly reject a request the real loaded window would accept. So the abort - // only consults the genuinely-reported window (e.g. LM Studio's loaded - // n_ctx); `None` ⇒ window unknown ⇒ no hard abort, trimming still runs. - let loaded_context_window = if model_is_local { - provider.loaded_context_window(model).await - } else { - None - }; - if let Some(loaded) = loaded_context_window { - tracing::debug!( - provider = provider_name, - model, - loaded_context_window = loaded, - "[agent_loop] authoritative loaded context window resolved (pre-dispatch prefix abort armed)" - ); - } - let mut context_guard = effective_context_window - .map(ContextGuard::with_context_window) - .unwrap_or_else(ContextGuard::new); - let mut turn_cost = TurnCost::new(); - - // Compiled digest of this run's tool calls + results, for a graceful - // checkpoint if the iteration cap is hit. Accumulated as the loop runs so - // it survives history trimming. - let mut run_tool_digest = String::new(); - - // Announce turn start. Lifecycle (turn/iteration) events are `.await`-ed so - // they survive downstream backpressure — dropping one would desync the - // web-channel progress bridge. - progress.turn_started().await; - - let stop_hooks = current_stop_hooks(); - // Repeated-failure circuit breaker — halts with a root cause rather than - // grinding to `max_iterations`. - let mut failure_guard = RepeatFailureGuard::new(); - // No-progress narration breaker — trips when the model re-emits the same - // response + tool call across iterations even when each call "succeeds" - // (the gap left by the failure guard + per-generation frequency_penalty). - let mut repeat_guard = RepeatOutputGuard::new(); - // Repeated-CALL breaker — trips when the model re-issues the identical - // `(tool, args)` batch back-to-back even when each call SUCCEEDS (the gap - // left by the failure guard, which resets on success, and the repeat-output - // guard, which also keys on narration text so varied prose evades it). - let mut call_guard = RepeatCallGuard::new(); - let mut halt_reason: Option = None; - for iteration in 0..max_iterations { - progress - .iteration_started((iteration + 1) as u32, max_iterations as u32) - .await; - - // ── Stop hooks: policy check before the next LLM call ── - if !stop_hooks.is_empty() { - let state = TurnState { - iteration: (iteration + 1) as u32, - max_iterations: max_iterations as u32, - cost: &turn_cost, - model, - }; - for hook in &stop_hooks { - match hook.check(&state).await { - StopDecision::Continue => {} - StopDecision::Stop { reason } => { - tracing::warn!( - iteration = (iteration + 1), - hook = hook.name(), - reason = %reason, - "[agent_loop] stop hook triggered — aborting turn" - ); - anyhow::bail!("Agent turn stopped by hook '{}': {reason}", hook.name()); - } - } - } - } - - // ── Context guard: check utilization before each LLM call ── - match context_guard.check() { - ContextCheckResult::Ok => {} - ContextCheckResult::CompactionNeeded => { - tracing::warn!( - iteration, - "[agent_loop] context guard: compaction needed (>{:.0}% full)", - crate::openhuman::context::guard::COMPACTION_TRIGGER_THRESHOLD * 100.0 - ); - // Engine-level LLM autocompaction (sub-agent path opts in via - // `autocompact`; the main `Agent` path is `None` and compacts in - // `before_dispatch` instead). Runs BEFORE the hard token-budget - // trim below so the summary captures content the trim would - // otherwise drop. Feeds the guard's circuit breaker so three - // consecutive failures disable it and the next `check()` returns - // `ContextExhausted` rather than looping. - if let Some(ac) = autocompact { - let summary_model = ac.summarizer_model.as_deref().unwrap_or(model); - match summarize_chat_history( - provider, - history, - summary_model, - ac.keep_recent, - ac.temperature, - ) - .await - { - Ok(stats) if stats.messages_removed > 0 => { - context_guard.record_compaction_success(); - tracing::info!( - iteration, - messages_removed = stats.messages_removed, - approx_tokens_freed = stats.approx_tokens_freed, - "[agent_loop] engine autocompaction freed context" - ); - } - Ok(_) => { - tracing::debug!( - iteration, - "[agent_loop] engine autocompaction: nothing to summarize \ - (history below keep_recent); relying on token-budget trim" - ); - } - Err(e) => { - context_guard.record_compaction_failure(); - tracing::warn!( - iteration, - error = %e, - "[agent_loop] engine autocompaction failed" - ); - } - } - } - } - ContextCheckResult::ContextExhausted { - utilization_pct, - reason, - } => { - let msg = format!("Context window exhausted ({utilization_pct}% full): {reason}"); - crate::core::observability::report_error( - msg.as_str(), - "agent", - "context_exhausted", - &[ - ("provider", provider_name), - ("model", model), - ("utilization_pct", &utilization_pct.to_string()), - ], - ); - anyhow::bail!(msg); - } - } - - if let Some(context_window) = effective_context_window { - let budget_outcome = trim_chat_messages_to_budget(history, context_window); - if budget_outcome.trimmed { - log::warn!( - "[agent_loop] pre-dispatch history trimmed model={} context_window={} original_tokens={} final_tokens={} messages_removed={}", - model, - context_window, - budget_outcome.original_tokens, - budget_outcome.final_tokens, - budget_outcome.messages_removed - ); - } else { - tracing::debug!( - iteration, - model, - context_window, - estimated_tokens = budget_outcome.final_tokens, - "[agent_loop] pre-dispatch token budget ok" - ); - } - } - - // Caller-specific pre-dispatch work (e.g. Agent's ContextManager). - observer.before_dispatch(history, tools, iteration).await?; - - // ── Run queue drain: inject steers/collects at safe boundary ── - // - // Session-backed agents rebuild `history` from typed conversation state - // inside `before_dispatch`; draining before that rebuild loses injected - // messages. Drain here so background events and mid-turn messages are - // present in the exact provider request about to be sent. - if let Some(ref rq) = run_queue { - if rq.has_pending_injections().await { - let steers = rq.drain_steers().await; - let collects = rq.drain_collects().await; - for s in &steers { - log::info!( - "[run_queue] injecting steer iteration={} thread_id={} chars={}", - iteration + 1, - s.thread_id, - s.text.len() - ); - let steer_content = format!("[User steering message]: {}", s.text); - history.push(ChatMessage::user(steer_content)); - crate::core::event_bus::publish_global( - crate::core::event_bus::DomainEvent::RunQueueMessageDelivered { - thread_id: s.thread_id.clone(), - mode: "steer".to_string(), - iteration: (iteration + 1) as u32, - }, - ); - } - for c in &collects { - log::info!( - "[run_queue] injecting collect iteration={} thread_id={} chars={}", - iteration + 1, - c.thread_id, - c.text.len() - ); - let collect_content = format!("[Additional context from user]: {}", c.text); - history.push(ChatMessage::user(collect_content)); - crate::core::event_bus::publish_global( - crate::core::event_bus::DomainEvent::RunQueueMessageDelivered { - thread_id: c.thread_id.clone(), - mode: "collect".to_string(), - iteration: (iteration + 1) as u32, - }, - ); - } - } - } - - tracing::debug!(iteration, "[agent_loop] sending LLM request"); - let image_marker_count = multimodal::count_image_markers(history); - // Whether *this turn's model* accepts image input. The per-model/tier - // flag (`current_model_vision`, set at session build from - // `oh_tier_supports_vision` + the user's `model_registry.vision`) is the - // source of truth and is consulted FIRST. The provider-level - // `supports_vision()` is too coarse on the managed backend — it - // advertises `vision: true` for the backend as a whole, which would - // wrongly rehydrate images for non-vision tiers like `chat-v1` (the - // orchestrator) and 400 on `image_url`. So the provider flag is only a - // fallback when no per-model scope is active (CLI / direct invocation / - // tests). This keeps the placeholder on non-vision models and lets only - // the vision sub-agent's model rehydrate the image. - let has_vision = turn_accepts_images( - crate::openhuman::agent::harness::model_vision_context::current_model_vision(), - provider.supports_vision(), - ); - if image_marker_count > 0 && !has_vision { - let cap_err = ProviderCapabilityError { - provider: provider_name.to_string(), - capability: "vision".to_string(), - message: format!( - "received {image_marker_count} image marker(s), but this provider does not support vision input" - ), - }; - crate::core::observability::report_error( - &cap_err, - "agent", - "provider_capability", - &[ - ("provider", provider_name), - ("capability", "vision"), - ("model", model), - ], - ); - return Err(cap_err.into()); - } - - // [image sidecar] Rehydrate `[Image: … #att:]` placeholders back to - // inline `[IMAGE:data:…]` from the process stash — but ONLY for - // vision-capable models. Non-vision models keep the text placeholder - // (no `[IMAGE:` markers ⇒ the capability gate above never fires, and no - // multi-MB payload is sent). The rehydrated copy is provider-only and is - // never persisted back to `history`. - let has_image_placeholders = multimodal::has_image_placeholders(history); - let rehydrated_history = if has_vision && has_image_placeholders { - tracing::debug!( - target: "multimodal", - has_vision, - history_len = history.len(), - "[image-sidecar] rehydrating image placeholders for vision-capable provider" - ); - Some(multimodal::rehydrate_image_placeholders(history)) - } else { - if has_image_placeholders { - tracing::debug!( - target: "multimodal", - has_vision, - "[image-sidecar] image placeholders present but provider is non-vision — keeping text placeholders" - ); - } - None - }; - let provider_history: &[_] = match rehydrated_history.as_ref() { - Some(v) => v, - None => history, - }; - - let prepared_messages = multimodal::prepare_messages_for_provider( - provider_history, - multimodal_config, - multimodal_file_config, - ) - .await?; - - // Re-run the context-window trim now that multimodal expansion may - // have inlined up to `max_extracted_text_chars` per file (default 50k - // chars ≈ 12k tokens) into the user message body. Without this - // second pass the provider can receive payloads past the model's - // context window — the pre-dispatch trim above was sized for the - // *original* marker text, not the rendered - // [FILE-EXTRACTED]/[FILE-ATTACHED]/[IMAGE:data:…] blocks. - let mut prepared_messages_vec = prepared_messages.messages; - if let Some(context_window) = effective_context_window { - let budget_outcome = - trim_chat_messages_to_budget(&mut prepared_messages_vec, context_window); - if budget_outcome.trimmed { - log::warn!( - "[agent_loop] post-multimodal provider messages trimmed model={} context_window={} original_tokens={} final_tokens={} messages_removed={}", - model, - context_window, - budget_outcome.original_tokens, - budget_outcome.final_tokens, - budget_outcome.messages_removed - ); - } - } - - // Pre-dispatch guard for the un-evictable-prefix overflow - // (TAURI-RUST-6V0 / #3550). Trimming above can only drop conversation - // history — never the system prefix or the current user turn. When a - // *local* model is loaded with a context window smaller than that - // un-evictable prefix (the runtime `n_keep >= n_ctx`), no amount of - // trimming can fit the prompt, so dispatching guarantees an opaque - // upstream `400`. Detect it here and surface the remedy the user - // actually controls — reload the model with a larger context length — - // instead of letting the cryptic provider error fly. - // - // Gated on `loaded_context_window`, the model's **authoritative** - // runtime window — `Some` only for a routed-local model whose runtime - // actually reports its loaded `n_ctx` (e.g. LM Studio). A guessed window - // (profile default / conservative floor) is deliberately NOT used here: - // it is safe to trim against (over-trim just costs reply room) but must - // not drive a hard "reload with a larger context length" abort, which - // would wrongly reject a request the real loaded window would accept - // (Codex P1 review on PR #3771). This is an expected user-state - // condition (S3.5: preventable-user-state), so it is demoted from Sentry - // via `report_error_or_expected` (its Display string matches - // `is_context_window_exceeded_message`). - if let Some(loaded) = loaded_context_window { - if let Some(prefix_err) = - crate::openhuman::agent::harness::token_budget::unevictable_prefix_overflow( - &prepared_messages_vec, - loaded, - ) - { - log::warn!( - "[agent_loop] un-evictable prefix overflows local context window — aborting pre-dispatch model={} loaded_context_window={} prefix_tokens={} max_input_tokens={}", - model, - loaded, - prefix_err.prefix_tokens, - prefix_err.max_input_tokens - ); - crate::core::observability::report_error_or_expected( - &prefix_err, - "agent", - "context_prefix_too_large", - &[ - ("provider", provider_name), - ("model", model), - ("context_window", &loaded.to_string()), - ("prefix_tokens", &prefix_err.prefix_tokens.to_string()), - ], - ); - return Err(prefix_err.into()); - } - } - - // Recomputed each iteration: a `ToolSource` may register tools lazily - // mid-turn, so native-tool enablement can flip from off to on. - let request_tools = if provider.supports_native_tools() && !tools.request_specs().is_empty() - { - Some(tools.request_specs()) - } else { - None - }; - - // ProviderDelta → progress forwarder for this iteration (no-op for - // flavors that don't stream). Sender dropped after the chat call so the - // forwarder exits cleanly. - let (delta_tx_opt, delta_forwarder) = progress.make_stream_sink((iteration + 1) as u32); - - let chat_result = provider - .chat( - ChatRequest { - messages: &prepared_messages_vec, - tools: request_tools, - stream: delta_tx_opt.as_ref(), - // Cap the turn so reservation-pricing providers price their - // pre-flight against a realistic budget, not the full output - // window (TAURI-RUST-C62). - max_tokens: Some(max_output_tokens), - }, - model, - temperature, - ) - .await; - - drop(delta_tx_opt); - if let Some(handle) = delta_forwarder { - let _ = handle.await; - } - - let ( - response_text, - display_text, - reasoning_content, - tool_calls, - assistant_history_content, - native_tool_calls, - response_usage, - ) = match chat_result { - Ok(resp) => { - // Update context guard + cost with token usage from this response. - if let Some(ref usage) = resp.usage { - context_guard.update_usage(usage); - turn_cost.add_call(model, usage); - observer.record_usage(provider_name, model, usage); - tracing::debug!( - iteration, - input_tokens = usage.input_tokens, - output_tokens = usage.output_tokens, - context_window = usage.context_window, - cumulative_usd = turn_cost.total_usd(), - "[agent_loop] LLM response received" - ); - progress - .cost_updated(model, (iteration + 1) as u32, &turn_cost) - .await; - } else { - tracing::debug!( - iteration, - "[agent_loop] LLM response received (no usage info)" - ); - } - - let response_text = resp.text_or_empty().to_string(); - let (display_text, calls) = parser.parse(&resp); - - tracing::debug!( - iteration, - native_tool_calls = resp.tool_calls.len(), - parsed_tool_calls = calls.len(), - "[agent_loop] tool calls parsed" - ); - - let assistant_history_content = if resp.tool_calls.is_empty() { - response_text.clone() - } else { - build_native_assistant_history( - &response_text, - resp.reasoning_content.as_deref(), - &resp.tool_calls, - ) - }; - - let reasoning_content = resp.reasoning_content; - let native_calls = resp.tool_calls; - let response_usage = resp.usage; - ( - response_text, - display_text, - reasoning_content, - calls, - assistant_history_content, - native_calls, - response_usage, - ) - } - Err(e) => { - // Transient upstream failures are already classified + retried by - // reliable.rs and reported once when all providers are exhausted; - // re-reporting per iteration floods Sentry (OPENHUMAN-TAURI-3Y/3Z). - let transient = - crate::openhuman::inference::provider::reliable::is_rate_limited(&e) - || crate::openhuman::inference::provider::reliable::is_upstream_unhealthy( - &e, - ); - if transient { - tracing::warn!( - domain = "agent", - operation = "provider_chat", - provider = provider_name, - model = model, - iteration = iteration + 1, - error = %format!("{e:#}"), - "[agent] transient provider_chat failure — retried upstream" - ); - } else { - crate::core::observability::report_error_or_expected( - &e, - "agent", - "provider_chat", - &[ - ("provider", provider_name), - ("model", model), - ("iteration", &(iteration + 1).to_string()), - ], - ); - } - return Err(e); - } - }; - - if tool_calls.is_empty() { - tracing::debug!( - iteration, - "[agent_loop] no tool calls — returning final response" - ); - // The final answer is the narrative text, falling back to the raw - // response text when the parser stripped everything (mirrors the - // legacy `Agent::turn` `final_text` logic). - let final_out = if display_text.is_empty() { - response_text.clone() - } else { - display_text.clone() - }; - // A completion with no text *and* no tool calls is a degenerate - // response. Callers that disallow it (Agent::turn) surface a typed - // error instead of a silent blank reply; the channel/subagent loops - // return it verbatim. - if final_out.trim().is_empty() && !observer.allow_empty_final() { - log::warn!( - "[agent_loop] provider returned an empty final response (i={}, no text, no tool calls) — surfacing as error", - iteration + 1 - ); - return Err( - crate::openhuman::agent::error::AgentError::EmptyProviderResponse { - iteration: iteration + 1, - } - .into(), - ); - } - // No tool calls — final response. Relay the text in small chunks - // when a streaming draft sink exists. - if let Some(ref tx) = on_delta { - let mut chunk = String::new(); - for word in final_out.split_inclusive(char::is_whitespace) { - chunk.push_str(word); - if chunk.len() >= STREAM_CHUNK_MIN_CHARS - && tx.send(std::mem::take(&mut chunk)).await.is_err() - { - break; // receiver dropped - } - } - if !chunk.is_empty() { - let _ = tx.send(chunk).await; - } - } - let mut assistant_msg = ChatMessage::assistant(response_text.clone()); - if let Some(turn_usage) = transcript_turn_usage( - provider_name, - model, - response_usage.as_ref(), - reasoning_content.as_deref(), - &[], - iteration, - ) { - transcript::attach_turn_usage_metadata(&mut assistant_msg, &turn_usage); - } - history.push(assistant_msg); - observer - .on_assistant( - &final_out, - &response_text, - reasoning_content.as_deref(), - &[], - &[], - iteration, - true, - ) - .await; - observer.after_iteration(history, iteration); - log::info!( - "[agent_loop] turn complete: iters={} provider_calls={} tokens_in={} tokens_out={} cached_in={} usd={:.4}", - (iteration + 1), - turn_cost.call_count, - turn_cost.input_tokens, - turn_cost.output_tokens, - turn_cost.cached_input_tokens, - turn_cost.total_usd(), - ); - progress.turn_completed((iteration + 1) as u32).await; - return Ok(TurnEngineOutcome { - text: final_out, - iterations: (iteration + 1) as u32, - cost: turn_cost, - stop: TurnStop::Final, - early_exit_tool: None, - }); - } - - // Polling/wait tools (e.g. `wait_subagent`) are contractually re-invoked - // with identical args + narration on each timeout while the work is still - // running, so an all-poll batch is legitimate progress, not a no-progress - // repeat. Exempt them from the no-progress breakers: reset the - // repeat-output guard here and skip its check, and skip the - // post-execution repeat-call guard below (Codex P1 on #4230). The - // iteration cap + cost budget still bound the wait. - let all_poll_exempt = tool_calls - .iter() - .all(|c| super::super::tool_loop::is_repeat_call_exempt(&c.name)); - if all_poll_exempt { - repeat_guard.reset(); - } - - // No-progress narration breaker: if this iteration's assistant output - // (text + tool-call name/args) is byte-identical to the previous N in a - // row, the run is stuck re-issuing the same step. Halt with a summary - // rather than grinding to the iteration cap. Checked BEFORE executing - // the (repeated) tool call so we don't burn another no-op iteration. - if !all_poll_exempt { - let mut sig = response_text.trim().to_string(); - for call in &tool_calls { - sig.push('\u{1}'); - sig.push_str(&call.name); - sig.push('\u{1}'); - sig.push_str(&call.arguments.to_string()); - } - if let Some(reason) = repeat_guard.record(&sig) { - tracing::warn!( - iteration, - "[agent_loop] repeat-output circuit breaker tripped — identical response+tool-call repeated; halting with no-progress summary" - ); - let mut assistant_msg = ChatMessage::assistant(assistant_history_content.clone()); - if let Some(turn_usage) = transcript_turn_usage( - provider_name, - model, - response_usage.as_ref(), - reasoning_content.as_deref(), - &native_tool_calls, - iteration, - ) { - transcript::attach_turn_usage_metadata(&mut assistant_msg, &turn_usage); - } - history.push(assistant_msg); - // Mirror the assistant turn to the observer like every other - // assistant-append path, so transcript/mirroring isn't skipped - // for the final repeated iteration on this early exit. - observer - .on_assistant( - &display_text, - &response_text, - reasoning_content.as_deref(), - &native_tool_calls, - &tool_calls, - iteration, - false, - ) - .await; - observer.after_iteration(history, iteration); - progress.turn_completed((iteration + 1) as u32).await; - return Ok(TurnEngineOutcome { - text: reason, - iterations: (iteration + 1) as u32, - cost: turn_cost, - stop: TurnStop::Halted, - early_exit_tool: None, - }); - } - } - - // Print any text the LLM produced alongside tool calls (unless silent) - if !silent && !display_text.is_empty() { - print!("{display_text}"); - let _ = std::io::stdout().flush(); - } - - // Execute each tool call and build results. `individual_results` tracks - // per-call output so native-mode history can emit one `role: tool` - // message per call with the correct id. - let mut tool_results = String::new(); - let mut individual_results: Vec = Vec::new(); - let mut early_exit_tool: Option = None; - // Tracks whether every executed call this iteration succeeded — feeds the - // success-gated repeat-call breaker below (#4095). Failures are the - // failure guard's domain (with its per-class thresholds), so a batch with - // any failure resets the repeat-call streak instead of counting it. - let mut all_calls_succeeded = true; - for (call_idx, call) in tool_calls.iter().enumerate() { - // Stable id threaded through the start/complete pair. The fallback - // includes `call_idx` to stay unique when the same tool name - // appears multiple times in one iteration. - let progress_call_id = call - .id - .clone() - .unwrap_or_else(|| format!("loop-{iteration}-{call_idx}-{}", call.name)); - - // Full per-call lifecycle is owned by the ToolSource. - let outcome = tools - .execute_call(call, iteration, progress, &progress_call_id) - .await; - - individual_results.push(outcome.text.clone()); - if !outcome.success { - all_calls_succeeded = false; - } - let _ = writeln!( - tool_results, - "\n{}\n", - call.name, outcome.text - ); - - // Record this call in the run digest (output truncated) for a - // possible max-iteration checkpoint. - let _ = writeln!( - run_tool_digest, - "- {} [{}]: {}", - call.name, - if outcome.success { "ok" } else { "failed" }, - truncate_with_ellipsis(&outcome.text, 800) - ); - - observer.on_tool_result( - &progress_call_id, - &call.name, - &outcome.text, - outcome.success, - iteration, - ); - - // Repeated-failure circuit breaker (shared guard). - if let Some(reason) = failure_guard.record( - &call.name, - &call.arguments.to_string(), - outcome.success, - &outcome.text, - ) { - tracing::warn!( - iteration, - tool = call.name.as_str(), - "[agent_loop] circuit breaker tripped — halting with root cause" - ); - halt_reason = Some(reason); - // Stop executing the rest of this assistant message's tool-call - // batch (#3104). Native-tool providers can emit multiple tool - // calls in one message; without this break the loop would drain - // the remaining calls — and on a permanent inference failure - // (out of budget / provider-config) that means launching the - // *next* paid sub-agent delegation right after the first one - // proved the wall is unrecoverable. Breaking here makes the - // "halt on the first occurrence" guarantee hold for batched - // calls too. The tool results recorded so far are still threaded - // into history below, so the caller keeps full context. - break; - } - - // Early-exit when a sub-agent calls ask_user_clarification: - // the tool returned successfully with the question text — stop - // the loop so the runner can checkpoint and surface the pause. - if early_exit_tool_names.contains(&call.name.as_str()) && outcome.success { - tracing::info!( - iteration, - tool = call.name.as_str(), - "[agent_loop] early-exit tool detected — requesting early exit" - ); - early_exit_tool = Some(call.name.clone()); - break; - } - } - - // A circuit-breaker / early-exit `break` can stop the batch before every - // tool call ran, so `individual_results` (one entry per EXECUTED call) - // may be shorter than `native_tool_calls` (every call the model emitted). - // The persisted assistant message must reference ONLY the executed calls: - // a native-mode assistant turn carrying N `tool_call` ids followed by - // fewer than N `role: tool` results is rejected by OpenAI-compatible - // providers ("an assistant message with tool_calls must be followed by - // tool messages responding to each tool_call_id") on the next request — - // exactly the raw `ChatMessage` histories used by run_tool_call_loop / - // the sub-agent paths (Codex review #3779). Trim the persisted tool-call - // list to the executed prefix so call-ids and tool-results stay in - // lockstep. `tool_calls` is a 1:1, same-order map of `native_tool_calls` - // (see `parse_structured_tool_calls`), so the executed prefix is simply - // the first `individual_results.len()` native calls. - let executed = individual_results.len(); - let executed_native_calls = &native_tool_calls[..executed.min(native_tool_calls.len())]; - // The parsed list is a 1:1, same-order map of the native list, so the - // executed prefix lines up. Trim it too: the typed-history observer - // (`turn_engine_adapter::persisted_tool_calls`) builds the `Agent::turn` - // `AssistantToolCalls` entry from these, and would otherwise persist a - // tool-call for every emitted call while only collecting results for the - // executed prefix — the same orphaned-id mismatch in the raw-ChatMessage - // path (Codex review #3779). - let executed_parsed_calls = &tool_calls[..executed.min(tool_calls.len())]; - let assistant_history_content = if executed < native_tool_calls.len() { - tracing::debug!( - iteration, - emitted = native_tool_calls.len(), - executed, - "[agent_loop] batch truncated before all tool calls ran — trimming \ - persisted assistant tool-calls to the executed prefix so tool_call_ids \ - match tool-results (no orphaned id)" - ); - // Rebuild from the executed prefix. Empty prefix (a break before the - // first call could ever produce one) degrades to the plain - // response-text assistant message, mirroring the no-tool-call path. - if executed_native_calls.is_empty() { - response_text.clone() - } else { - build_native_assistant_history( - &response_text, - reasoning_content.as_deref(), - executed_native_calls, - ) - } - } else { - assistant_history_content - }; - - // Add assistant message with tool calls + tool results to history. - // Native mode: JSON-structured messages so convert_messages() can - // reconstruct OpenAI-format tool_calls + tool result messages. Prompt - // mode: XML-based text format. - let mut assistant_msg = ChatMessage::assistant(assistant_history_content); - if let Some(turn_usage) = transcript_turn_usage( - provider_name, - model, - response_usage.as_ref(), - reasoning_content.as_deref(), - executed_native_calls, - iteration, - ) { - transcript::attach_turn_usage_metadata(&mut assistant_msg, &turn_usage); - } - history.push(assistant_msg); - observer - .on_assistant( - &display_text, - &response_text, - reasoning_content.as_deref(), - executed_native_calls, - executed_parsed_calls, - iteration, - false, - ) - .await; - if native_tool_calls.is_empty() { - let content = format!("[Tool results]\n{tool_results}"); - observer.on_results_batch(&content, iteration); - history.push(ChatMessage::user(content)); - } else { - // Zip over the executed prefix only — one `role: tool` result per - // executed `tool_call_id`, matching the trimmed assistant message - // above so the next provider request has no orphaned tool-call id. - for (native_call, result) in executed_native_calls.iter().zip(individual_results.iter()) - { - let tool_msg = serde_json::json!({ - "tool_call_id": native_call.id, - "content": result, - }); - history.push(ChatMessage::tool(tool_msg.to_string())); - } - } - - observer.after_iteration(history, iteration); - - // Early-exit for ask_user_clarification: history already has the - // tool call + result appended, observer persisted the transcript. - // Return the clarification output so the sub-agent runner can - // checkpoint and propagate the pause to the orchestrator. - if let Some(ref exit_tool) = early_exit_tool { - tracing::info!( - iteration, - tool = exit_tool.as_str(), - "[agent_loop] early exit — returning with tool result as output" - ); - let exit_text = individual_results.last().cloned().unwrap_or_default(); - progress.turn_completed((iteration + 1) as u32).await; - return Ok(TurnEngineOutcome { - text: exit_text, - iterations: (iteration + 1) as u32, - cost: turn_cost, - stop: TurnStop::EarlyExit, - early_exit_tool, - }); - } - - // Repeat-call breaker for SUCCESSFUL no-op loops (#4095). The failure - // guard above owns repeated *failures* (with per-class thresholds) and - // resets on success, so an identical call that keeps SUCCEEDING with no - // new result slips past it. Count it here — post-execution and gated on - // success — so failing batches stay the failure guard's domain and - // poll/wait tools (`wait_subagent`) stay exempt. Sets `halt_reason`, - // handled just below; tool results are already in `history`. - if halt_reason.is_none() { - if all_poll_exempt || !all_calls_succeeded { - call_guard.reset(); - } else { - let mut call_sig = String::new(); - for call in &tool_calls { - call_sig.push('\u{1}'); - call_sig.push_str(&call.name); - call_sig.push('\u{1}'); - call_sig.push_str(&call.arguments.to_string()); - } - if let Some(reason) = call_guard.record(&call_sig) { - tracing::warn!( - iteration, - "[agent_loop] repeat-call circuit breaker tripped — identical successful (tool,args) batch repeated; halting with no-progress summary" - ); - halt_reason = Some(reason); - } - } - } - - // Circuit breaker tripped this iteration: return the root-cause summary - // instead of looping to `max_iterations`. Tool results are already in - // `history`, so the caller still has full context. - if let Some(reason) = halt_reason.take() { - // Mirror the normal-completion path: emit turn-completed before the - // early return so progress consumers don't stay in-flight. - progress.turn_completed((iteration + 1) as u32).await; - return Ok(TurnEngineOutcome { - text: reason, - iterations: (iteration + 1) as u32, - cost: turn_cost, - stop: TurnStop::Halted, - early_exit_tool: None, - }); - } - } - - // Iteration cap reached — hand off to the checkpoint strategy (error vs - // summarize). The accumulated digest lets a summarizing strategy produce a - // resumable, root-cause-aware checkpoint. - let digest = if run_tool_digest.is_empty() { - "(no tool calls completed)" - } else { - run_tool_digest.as_str() - }; - let co = checkpoint.on_max_iter(digest, max_iterations).await?; - // Fold any summarization-call usage into the turn cost + observer so token - // accounting stays complete. - if let Some(ref u) = co.usage { - turn_cost.add_call(model, u); - observer.record_usage(provider_name, model, u); - } - // Emit the terminal lifecycle event on this successful (checkpoint) exit - // too, so consumers aren't left waiting — matching the final-response and - // circuit-breaker paths. - progress.turn_completed(max_iterations as u32).await; - Ok(TurnEngineOutcome { - text: co.text, - iterations: max_iterations as u32, - cost: turn_cost, - stop: TurnStop::Cap, - early_exit_tool: None, - }) -} - -#[cfg(test)] -mod gate_tests { - use super::turn_accepts_images; - - #[test] - fn per_model_flag_overrides_coarse_provider_flag() { - // Managed backend advertises provider-level vision=true, but a non-vision - // tier (e.g. chat-v1 orchestrator) must keep the placeholder: per-model - // flag false wins → no rehydrate → no `image_url` 400. - assert!(!turn_accepts_images(Some(false), true)); - // Vision tier (vision-v1 / the vision sub-agent): per-model flag true → - // rehydrate even if the provider flag were false. - assert!(turn_accepts_images(Some(true), false)); - } - - #[test] - fn falls_back_to_provider_when_no_scope() { - // CLI / direct invocation / tests: no per-model scope → provider flag. - assert!(turn_accepts_images(None, true)); - assert!(!turn_accepts_images(None, false)); - } -} - -#[cfg(test)] -#[path = "core_tests.rs"] -mod tests; diff --git a/src/openhuman/agent/harness/engine/core_tests.rs b/src/openhuman/agent/harness/engine/core_tests.rs deleted file mode 100644 index f5b937409..000000000 --- a/src/openhuman/agent/harness/engine/core_tests.rs +++ /dev/null @@ -1,1005 +0,0 @@ -//! Integration tests for the turn engine's autocompaction wiring. -//! -//! Layer 1 (`context::summarizer::tests`) proves `summarize_chat_history` -//! summarizes correctly when called directly. These tests prove the *glue*: -//! that `run_turn_engine` actually invokes it on its own when the context -//! guard reports the window is filling — and, just as importantly, that it -//! does NOT when a caller opts out (`autocompact = None`, the main-agent / -//! channel path). Without these, the feature could silently regress (e.g. a -//! refactor passing `None`, or the `CompactionNeeded` arm never reaching the -//! hook) while every unit test stayed green. -//! -//! The whole flow is driven deterministically with no network: -//! * a scripted provider returns canned responses and reports usage that -//! pushes the guard past its 0.90 trigger (95k / 100k tokens); -//! * the provider pins `effective_context_window` to `None`, so the -//! pre-dispatch token-budget trims stay disabled — autocompaction is the -//! only thing that can mutate `history`; -//! * the first response carries a tool call so the loop runs a second -//! iteration, where `guard.check()` finally sees the recorded high usage. - -use super::*; -use crate::openhuman::agent::harness::engine::progress::NullProgress; -use crate::openhuman::agent::harness::engine::{ - DefaultParser, ErrorCheckpoint, NullObserver, ToolRunResult, ToolSource, -}; -use crate::openhuman::agent::harness::parse::ParsedToolCall; -use crate::openhuman::config::{MultimodalConfig, MultimodalFileConfig}; -use crate::openhuman::context::EngineAutocompact; -use crate::openhuman::inference::provider::{ChatResponse, ToolCall, UsageInfo}; -use async_trait::async_trait; -use std::sync::Mutex; - -/// Provider that replays a queue of `chat()` responses and records every -/// `chat_with_history()` call — that method is ONLY reached via the -/// autocompaction summary, so its call count is a clean "compaction fired" -/// signal independent of inspecting `history`. -struct CompactionProvider { - responses: Mutex>, - summarize_calls: Mutex, -} - -impl CompactionProvider { - fn new(responses: Vec) -> Arc { - Arc::new(Self { - responses: Mutex::new(responses), - summarize_calls: Mutex::new(0), - }) - } - fn summarize_call_count(&self) -> usize { - *self.summarize_calls.lock().unwrap() - } -} - -#[async_trait] -impl Provider for CompactionProvider { - async fn chat_with_system( - &self, - _system: Option<&str>, - _message: &str, - _model: &str, - _temperature: f64, - ) -> anyhow::Result { - Ok("noop".into()) - } - - async fn chat_with_history( - &self, - _messages: &[ChatMessage], - _model: &str, - _temperature: f64, - ) -> anyhow::Result { - *self.summarize_calls.lock().unwrap() += 1; - Ok("COMPACTED-SUMMARY-BODY".into()) - } - - async fn chat( - &self, - _request: ChatRequest<'_>, - _model: &str, - _temperature: f64, - ) -> anyhow::Result { - let mut q = self.responses.lock().unwrap(); - Ok(if q.is_empty() { - ChatResponse { - text: Some("FINAL".into()), - tool_calls: vec![], - usage: None, - reasoning_content: None, - } - } else { - q.remove(0) - }) - } - - fn supports_native_tools(&self) -> bool { - true - } - - /// Pin the effective context window to `None` so the pre-dispatch - /// token-budget trims stay disabled deterministically — autocompaction is - /// then the only thing that can mutate `history`. Don't rely on the - /// unknown-model fallback (it would silently re-enable trimming if the - /// static table ever learned the test's model id). - async fn effective_context_window(&self, _model: &str) -> Option { - None - } -} - -/// Minimal tool source: advertises nothing and reports success for any call, -/// so the engine's tool-execution seam is satisfied without real tools. -struct NoopToolSource { - specs: Vec, -} - -#[async_trait] -impl ToolSource for NoopToolSource { - fn request_specs(&self) -> &[crate::openhuman::tools::ToolSpec] { - &self.specs - } - - async fn execute_call( - &mut self, - _call: &ParsedToolCall, - _iteration: usize, - _progress: &dyn super::ProgressReporter, - _progress_call_id: &str, - ) -> ToolRunResult { - ToolRunResult { - text: "ok".into(), - success: true, - } - } -} - -/// First response: a tool call (so the loop runs a 2nd iteration) plus usage at -/// 95% of a 100k window (so the guard trips on that 2nd iteration). Second -/// response: plain final text, no tools, ending the loop. -fn scripted_responses() -> Vec { - vec![ - ChatResponse { - text: Some(String::new()), - tool_calls: vec![ToolCall { - id: "call-1".into(), - name: "noop".into(), - arguments: "{}".into(), - extra_content: None, - }], - usage: Some(UsageInfo { - input_tokens: 95_000, - output_tokens: 0, - context_window: 100_000, - cached_input_tokens: 0, - charged_amount_usd: 0.0, - }), - reasoning_content: None, - }, - ChatResponse { - text: Some("FINAL".into()), - tool_calls: vec![], - usage: None, - reasoning_content: None, - }, - ] -} - -/// Seed history: a leading system prompt that must survive compaction, plus -/// distinctly-labelled middle messages that must be summarized away. -fn seed_history() -> Vec { - vec![ - ChatMessage::system("SYSTEM"), - ChatMessage::user("TASK"), - ChatMessage::assistant("MID-1"), - ChatMessage::user("MID-2"), - ChatMessage::assistant("MID-3"), - ChatMessage::user("MID-4"), - ChatMessage::user("TAIL-1"), - ChatMessage::assistant("TAIL-2"), - ] -} - -#[allow(clippy::too_many_arguments)] -async fn run( - provider: &dyn Provider, - history: &mut Vec, - autocompact: Option<&EngineAutocompact>, -) -> TurnEngineOutcome { - let mut tool_source = NoopToolSource { specs: Vec::new() }; - let progress = NullProgress; - let mut observer = NullObserver; - let checkpoint = ErrorCheckpoint; - let parser = DefaultParser; - let multimodal = MultimodalConfig::default(); - let multimodal_files = MultimodalFileConfig::default(); - - run_turn_engine( - provider, - history, - &mut tool_source, - &progress, - &mut observer, - &checkpoint, - &parser, - "test-provider", - // The provider pins `effective_context_window` to `None`, so the - // token-budget trims stay disabled, isolating autocompaction as the - // only mutator. The model id is otherwise irrelevant here. - "ctx-test-model-xyz", - 0.0, - true, - &multimodal, - &multimodal_files, - 8, - crate::openhuman::inference::provider::AGENT_TURN_MAX_OUTPUT_TOKENS, - None, - &[], - None, - autocompact, - ) - .await - .expect("turn engine should complete") -} - -#[tokio::test] -async fn engine_autocompacts_history_when_guard_trips() { - let provider = CompactionProvider::new(scripted_responses()); - let mut history = seed_history(); - - let autocompact = EngineAutocompact { - keep_recent: 2, - temperature: 0.2, - summarizer_model: None, - }; - - let outcome = run(provider.as_ref(), &mut history, Some(&autocompact)).await; - assert_eq!(outcome.text, "FINAL"); - - // The summary round-trip fired exactly once (only reachable via autocompact). - assert_eq!( - provider.summarize_call_count(), - 1, - "guard should have triggered exactly one autocompaction summary call" - ); - - // Leading system prompt survived verbatim at the head. - assert_eq!(history[0].role, "system"); - assert_eq!(history[0].content, "SYSTEM"); - - // The reference-only summary (carrying the stub body) is now in history. - assert!( - history.iter().any(|m| { - m.role == "system" - && m.content.contains("COMPACTED-SUMMARY-BODY") - && m.content.contains("REFERENCE ONLY") - && m.content.contains("END OF CONTEXT SUMMARY") - }), - "expected a reference-only summary message in history: {history:?}" - ); - - // Middle messages were collapsed into the summary, not left verbatim. - assert!( - !history.iter().any(|m| m.content == "MID-1"), - "old middle messages should have been summarized away: {history:?}" - ); -} - -// -- #3104 / Codex #3779 Finding B: batch-break leaves no orphaned tool-call id - - -/// Provider that emits a single assistant response carrying MULTIPLE native tool -/// calls in one message (the native-mode batch the reviewer flagged), then a -/// plain final text on any later call so the loop can terminate. -struct BatchToolCallProvider { - served: Mutex, - calls: Vec, -} - -impl BatchToolCallProvider { - fn new(calls: Vec) -> Arc { - Arc::new(Self { - served: Mutex::new(false), - calls, - }) - } -} - -#[async_trait] -impl Provider for BatchToolCallProvider { - async fn chat_with_system( - &self, - _system: Option<&str>, - _message: &str, - _model: &str, - _temperature: f64, - ) -> anyhow::Result { - Ok("noop".into()) - } - - async fn chat( - &self, - _request: ChatRequest<'_>, - _model: &str, - _temperature: f64, - ) -> anyhow::Result { - let mut served = self.served.lock().unwrap(); - if *served { - // Loop should have already halted on the first batch; this is only a - // safety net so the engine can never block waiting for more input. - return Ok(ChatResponse { - text: Some("FINAL".into()), - tool_calls: vec![], - usage: None, - reasoning_content: None, - }); - } - *served = true; - Ok(ChatResponse { - text: Some(String::new()), - tool_calls: self.calls.clone(), - usage: None, - reasoning_content: None, - }) - } - - fn supports_native_tools(&self) -> bool { - true - } - - async fn effective_context_window(&self, _model: &str) -> Option { - None - } -} - -/// Tool source whose FIRST executed call returns a terminal (budget-exhausted) -/// delegated-inference failure carrying the sub-agent dispatch wrapper, so the -/// shared `RepeatFailureGuard` halts on the first occurrence and the batch -/// breaks before the remaining call(s) run. Any later call would succeed — but -/// must never be reached. -struct FailFirstToolSource { - specs: Vec, - executed: Vec, -} - -#[async_trait] -impl ToolSource for FailFirstToolSource { - fn request_specs(&self) -> &[crate::openhuman::tools::ToolSpec] { - &self.specs - } - - async fn execute_call( - &mut self, - call: &ParsedToolCall, - _iteration: usize, - _progress: &dyn super::ProgressReporter, - _progress_call_id: &str, - ) -> ToolRunResult { - self.executed.push(call.name.clone()); - if self.executed.len() == 1 { - // Sub-agent dispatch wrapper (`failed and did not complete`) + a - // budget body → terminal inference failure → halt on first failure. - ToolRunResult { - text: "run_code failed and did not complete — no work was performed. \ - Error: {\"error\":\"insufficient balance — add credits\"}" - .into(), - success: false, - } - } else { - ToolRunResult { - text: "ok".into(), - success: true, - } - } - } -} - -#[allow(clippy::too_many_arguments)] -async fn run_with_source( - provider: &dyn Provider, - history: &mut Vec, - tool_source: &mut dyn ToolSource, -) -> TurnEngineOutcome { - let progress = NullProgress; - let mut observer = NullObserver; - let checkpoint = ErrorCheckpoint; - let parser = DefaultParser; - let multimodal = MultimodalConfig::default(); - let multimodal_files = MultimodalFileConfig::default(); - - run_turn_engine( - provider, - history, - tool_source, - &progress, - &mut observer, - &checkpoint, - &parser, - "test-provider", - "ctx-test-model-xyz", - 0.0, - true, - &multimodal, - &multimodal_files, - 8, - crate::openhuman::inference::provider::AGENT_TURN_MAX_OUTPUT_TOKENS, - None, - &[], - None, - None, - ) - .await - .expect("turn engine should complete") -} - -/// Collect the `tool_call` ids referenced by an assistant message's native-mode -/// JSON content (`{"content":…,"tool_calls":[{"id":…}, …]}`). Returns empty for -/// non-native / non-tool-call assistant messages. -fn assistant_tool_call_ids(content: &str) -> Vec { - serde_json::from_str::(content) - .ok() - .and_then(|v| { - v.get("tool_calls").and_then(|tc| tc.as_array()).map(|arr| { - arr.iter() - .filter_map(|c| c.get("id").and_then(|i| i.as_str()).map(str::to_string)) - .collect() - }) - }) - .unwrap_or_default() -} - -/// Collect the `tool_call_id`s of every `role: tool` result message in history. -fn tool_result_ids(history: &[ChatMessage]) -> Vec { - history - .iter() - .filter(|m| m.role == "tool") - .filter_map(|m| { - serde_json::from_str::(&m.content) - .ok() - .and_then(|v| { - v.get("tool_call_id") - .and_then(|i| i.as_str()) - .map(str::to_string) - }) - }) - .collect() -} - -#[tokio::test] -async fn batch_break_trims_assistant_tool_calls_to_executed_no_orphan_id() { - // The model emits THREE native tool calls in one message. The first returns a - // terminal budget failure → the shared guard halts on the first occurrence - // and the batch breaks, so calls #2 and #3 never run. The persisted assistant - // message must reference ONLY the executed call id, and there must be exactly - // one matching `role: tool` result — no orphaned tool-call id that an - // OpenAI-compatible provider would reject on the next request. - let provider = BatchToolCallProvider::new(vec![ - ToolCall { - id: "call-A".into(), - name: "run_code".into(), - arguments: "{\"prompt\":\"a\"}".into(), - extra_content: None, - }, - ToolCall { - id: "call-B".into(), - name: "run_code".into(), - arguments: "{\"prompt\":\"b\"}".into(), - extra_content: None, - }, - ToolCall { - id: "call-C".into(), - name: "run_code".into(), - arguments: "{\"prompt\":\"c\"}".into(), - extra_content: None, - }, - ]); - let mut tool_source = FailFirstToolSource { - specs: Vec::new(), - executed: Vec::new(), - }; - let mut history = vec![ChatMessage::system("SYSTEM"), ChatMessage::user("TASK")]; - - let outcome = run_with_source(provider.as_ref(), &mut history, &mut tool_source).await; - - // Only the first call ran; the batch broke before #2/#3. - assert_eq!( - tool_source.executed.as_slice(), - ["run_code"], - "only the first tool call should have executed before the terminal halt" - ); - // The turn returned the root-cause halt summary (budget), not a generic stop. - assert!( - outcome.text.contains("out of inference budget"), - "expected the budget root-cause halt summary, got: {}", - outcome.text - ); - - // The persisted assistant message references ONLY the executed call id. - let assistant = history - .iter() - .find(|m| m.role == "assistant" && m.content.contains("tool_calls")) - .expect("an assistant message carrying tool_calls must be in history"); - let asst_ids = assistant_tool_call_ids(&assistant.content); - assert_eq!( - asst_ids, - vec!["call-A".to_string()], - "assistant tool-call list must be trimmed to the executed prefix \ - (no orphaned call-B/call-C): {asst_ids:?}" - ); - - // Exactly one tool result, matching that id — perfect id ↔ result pairing. - let result_ids = tool_result_ids(&history); - assert_eq!( - result_ids, - vec!["call-A".to_string()], - "exactly one tool-result, matching the single executed tool-call id: {result_ids:?}" - ); - - // The invariant the provider enforces: every persisted assistant tool-call id - // has a corresponding tool-result, and vice-versa (no orphans either way). - assert_eq!( - asst_ids, result_ids, - "tool-call ids and tool-result ids must be in lockstep after a batch break" - ); -} - -#[tokio::test] -async fn single_failing_call_pairs_id_with_result_boundary() { - // Boundary: a SINGLE native tool call that fails terminally. There is no - // un-executed suffix to trim, but the executed-prefix path must still produce - // exactly one assistant tool-call id paired with one tool-result (the - // degenerate case must not regress to zero results or an orphan). - let provider = BatchToolCallProvider::new(vec![ToolCall { - id: "only-1".into(), - name: "run_code".into(), - arguments: "{}".into(), - extra_content: None, - }]); - let mut tool_source = FailFirstToolSource { - specs: Vec::new(), - executed: Vec::new(), - }; - let mut history = vec![ChatMessage::system("SYSTEM"), ChatMessage::user("TASK")]; - - run_with_source(provider.as_ref(), &mut history, &mut tool_source).await; - - let assistant = history - .iter() - .find(|m| m.role == "assistant" && m.content.contains("tool_calls")) - .expect("assistant message with tool_calls"); - assert_eq!(assistant_tool_call_ids(&assistant.content), vec!["only-1"]); - assert_eq!(tool_result_ids(&history), vec!["only-1"]); -} - -#[tokio::test] -async fn full_batch_success_keeps_all_ids_paired() { - // Success path: when NO break happens (all calls run), every emitted tool-call - // id must be preserved and paired 1:1 with its result — proving the trim is - // additive (only fires on truncation) and never drops calls on the happy path. - struct AllOkToolSource { - specs: Vec, - } - #[async_trait] - impl ToolSource for AllOkToolSource { - fn request_specs(&self) -> &[crate::openhuman::tools::ToolSpec] { - &self.specs - } - async fn execute_call( - &mut self, - _call: &ParsedToolCall, - _iteration: usize, - _progress: &dyn super::ProgressReporter, - _progress_call_id: &str, - ) -> ToolRunResult { - ToolRunResult { - text: "ok".into(), - success: true, - } - } - } - - let provider = BatchToolCallProvider::new(vec![ - ToolCall { - id: "x1".into(), - name: "noop".into(), - arguments: "{}".into(), - extra_content: None, - }, - ToolCall { - id: "x2".into(), - name: "noop".into(), - arguments: "{}".into(), - extra_content: None, - }, - ]); - let mut tool_source = AllOkToolSource { specs: Vec::new() }; - let mut history = vec![ChatMessage::system("SYSTEM"), ChatMessage::user("TASK")]; - - run_with_source(provider.as_ref(), &mut history, &mut tool_source).await; - - let assistant = history - .iter() - .find(|m| m.role == "assistant" && m.content.contains("tool_calls")) - .expect("assistant message with tool_calls"); - let asst_ids = assistant_tool_call_ids(&assistant.content); - let result_ids = tool_result_ids(&history); - assert_eq!( - asst_ids, - vec!["x1".to_string(), "x2".to_string()], - "both emitted tool-call ids must be preserved on the all-success path" - ); - assert_eq!( - result_ids, - vec!["x1".to_string(), "x2".to_string()], - "both tool results must be present and ordered on the all-success path" - ); - assert_eq!(asst_ids, result_ids); -} - -#[tokio::test] -async fn engine_does_not_autocompact_when_opted_out() { - // Same guard-tripping scenario, but `autocompact = None` (the main-agent / - // channel path). The engine must NOT summarize — proving the behavior is - // gated on the opt-in, not on the guard alone. - let provider = CompactionProvider::new(scripted_responses()); - let mut history = seed_history(); - - let outcome = run(provider.as_ref(), &mut history, None).await; - assert_eq!(outcome.text, "FINAL"); - - assert_eq!( - provider.summarize_call_count(), - 0, - "no autocompaction summary call should happen when opted out" - ); - // Original middle messages remain untouched. - assert!(history.iter().any(|m| m.content == "MID-1")); - assert!( - !history - .iter() - .any(|m| m.content.contains("END OF CONTEXT SUMMARY")), - "no summary message should be inserted when opted out" - ); -} - -/// Provider that returns the SAME single tool call on every `chat()` — the -/// degenerate no-progress loop from #4095. Used to prove the repeat-call breaker -/// halts the turn before the iteration cap even though every call succeeds. -struct RepeatedCallProvider { - call: ToolCall, - counter: Mutex, -} - -#[async_trait] -impl Provider for RepeatedCallProvider { - async fn chat_with_system( - &self, - _system: Option<&str>, - _message: &str, - _model: &str, - _temperature: f64, - ) -> anyhow::Result { - Ok("noop".into()) - } - - async fn chat( - &self, - _request: ChatRequest<'_>, - _model: &str, - _temperature: f64, - ) -> anyhow::Result { - // Same (tool, args) every turn, but DIFFERENT narration each time, so the - // repeat-OUTPUT guard (which hashes narration too) keeps resetting and can - // never trip — only the repeat-CALL guard (keyed on the call alone) can - // catch this. That genuinely isolates the call breaker. - let n = { - let mut c = self.counter.lock().unwrap(); - *c += 1; - *c - }; - Ok(ChatResponse { - text: Some(format!("Thinking about it a different way, attempt {n}.")), - tool_calls: vec![self.call.clone()], - usage: None, - reasoning_content: None, - }) - } - - fn supports_native_tools(&self) -> bool { - true - } - - async fn effective_context_window(&self, _model: &str) -> Option { - None - } -} - -#[tokio::test] -async fn repeat_call_breaker_halts_identical_successful_calls_before_cap() { - // #4095: an identical `(tool, args)` call that SUCCEEDS every time must halt - // via the repeat-call breaker well before the iteration cap (8 in - // `run_with_source`), with a no-progress summary — not run to exhaustion. - struct AlwaysOkToolSource { - specs: Vec, - } - #[async_trait] - impl ToolSource for AlwaysOkToolSource { - fn request_specs(&self) -> &[crate::openhuman::tools::ToolSpec] { - &self.specs - } - async fn execute_call( - &mut self, - _call: &ParsedToolCall, - _iteration: usize, - _progress: &dyn super::ProgressReporter, - _progress_call_id: &str, - ) -> ToolRunResult { - ToolRunResult { - text: "[directory listing]".into(), - success: true, - } - } - } - - let provider = Arc::new(RepeatedCallProvider { - call: ToolCall { - id: "loop-call".into(), - name: "list_dir".into(), - arguments: "{\"path\":\"/app\"}".into(), - extra_content: None, - }, - counter: Mutex::new(0), - }); - let mut tool_source = AlwaysOkToolSource { specs: Vec::new() }; - let mut history = vec![ChatMessage::system("SYSTEM"), ChatMessage::user("TASK")]; - - let outcome = run_with_source(provider.as_ref(), &mut history, &mut tool_source).await; - - assert_eq!( - outcome.iterations, - crate::openhuman::agent::harness::tool_loop::REPEAT_CALL_THRESHOLD, - "should halt exactly when the identical-call streak hits the threshold" - ); - assert_eq!( - outcome.stop, - TurnStop::Halted, - "an identical-call stop is a circuit-breaker Halt, not a Cap or clean Final" - ); - assert!( - outcome.text.contains("same tool call") && outcome.text.contains("identical arguments"), - "halt text should be the no-progress summary: {}", - outcome.text - ); -} - -/// Provider that returns a tool call with a DIFFERENT argument each `chat()`, so -/// the repeat-call breaker never trips and the loop runs to the iteration cap. -struct VariedCallProvider { - counter: Mutex, -} - -#[async_trait] -impl Provider for VariedCallProvider { - async fn chat_with_system( - &self, - _system: Option<&str>, - _message: &str, - _model: &str, - _temperature: f64, - ) -> anyhow::Result { - Ok("noop".into()) - } - - async fn chat( - &self, - _request: ChatRequest<'_>, - _model: &str, - _temperature: f64, - ) -> anyhow::Result { - let n = { - let mut c = self.counter.lock().unwrap(); - *c += 1; - *c - }; - Ok(ChatResponse { - text: Some(format!("step {n}")), - tool_calls: vec![ToolCall { - id: format!("call-{n}"), - name: "read_file".into(), - // Distinct arg every turn → no repeat-call streak → runs to cap. - arguments: format!("{{\"path\":\"f{n}.txt\"}}"), - extra_content: None, - }], - usage: None, - reasoning_content: None, - }) - } - - fn supports_native_tools(&self) -> bool { - true - } - - async fn effective_context_window(&self, _model: &str) -> Option { - None - } -} - -#[tokio::test] -async fn iteration_cap_yields_turnstop_cap() { - // Varied, always-succeeding calls never trip a breaker, so the turn runs to - // the iteration cap (8 in `run_with_source`) and reports `TurnStop::Cap` — - // the signal the sub-agent runner maps to `Incomplete` (#4096). - struct AlwaysOkToolSource { - specs: Vec, - } - #[async_trait] - impl ToolSource for AlwaysOkToolSource { - fn request_specs(&self) -> &[crate::openhuman::tools::ToolSpec] { - &self.specs - } - async fn execute_call( - &mut self, - _call: &ParsedToolCall, - _iteration: usize, - _progress: &dyn super::ProgressReporter, - _progress_call_id: &str, - ) -> ToolRunResult { - ToolRunResult { - text: "ok".into(), - success: true, - } - } - } - - // A summarizing checkpoint (like the sub-agent path) so the cap yields - // Ok(TurnStop::Cap) instead of the ErrorCheckpoint's Err. - struct SummarizeCheckpoint; - #[async_trait] - impl super::super::CheckpointStrategy for SummarizeCheckpoint { - async fn on_max_iter( - &self, - _digest: &str, - _max: usize, - ) -> anyhow::Result { - Ok(super::super::CheckpointOutcome { - text: "CHECKPOINT".into(), - usage: None, - }) - } - } - - let provider = Arc::new(VariedCallProvider { - counter: Mutex::new(0), - }); - let mut tool_source = AlwaysOkToolSource { specs: Vec::new() }; - let mut history = vec![ChatMessage::system("SYSTEM"), ChatMessage::user("TASK")]; - let progress = NullProgress; - let mut observer = NullObserver; - let checkpoint = SummarizeCheckpoint; - let parser = DefaultParser; - let multimodal = MultimodalConfig::default(); - let multimodal_files = MultimodalFileConfig::default(); - - let outcome = run_turn_engine( - provider.as_ref(), - &mut history, - &mut tool_source, - &progress, - &mut observer, - &checkpoint, - &parser, - "test-provider", - "ctx-test-model-xyz", - 0.0, - true, - &multimodal, - &multimodal_files, - 8, - crate::openhuman::inference::provider::AGENT_TURN_MAX_OUTPUT_TOKENS, - None, - &[], - None, - None, - ) - .await - .expect("summarizing checkpoint returns Ok at the cap"); - - assert_eq!( - outcome.stop, - TurnStop::Cap, - "varied calls should run to the cap" - ); - assert_eq!(outcome.iterations, 8, "the cap is 8 iterations"); - assert_eq!(outcome.text, "CHECKPOINT"); -} - -/// Provider that emits an identical `wait_subagent` poll (same args, same -/// narration) for the first 5 turns — past BOTH breaker thresholds — then a -/// final answer. Proves polling tools are exempt from the no-progress breakers. -struct PollingProvider { - served: Mutex, -} - -#[async_trait] -impl Provider for PollingProvider { - async fn chat_with_system( - &self, - _system: Option<&str>, - _message: &str, - _model: &str, - _temperature: f64, - ) -> anyhow::Result { - Ok("noop".into()) - } - - async fn chat( - &self, - _request: ChatRequest<'_>, - _model: &str, - _temperature: f64, - ) -> anyhow::Result { - let n = { - let mut c = self.served.lock().unwrap(); - *c += 1; - *c - }; - if n <= 5 { - // Identical poll every turn — same tool, same args, same narration. - Ok(ChatResponse { - text: Some("Still waiting on the sub-agent.".into()), - tool_calls: vec![ToolCall { - id: format!("wait-{n}"), - name: "wait_subagent".into(), - arguments: "{\"task_id\":\"t1\"}".into(), - extra_content: None, - }], - usage: None, - reasoning_content: None, - }) - } else { - Ok(ChatResponse { - text: Some("DONE — the sub-agent finished.".into()), - tool_calls: vec![], - usage: None, - reasoning_content: None, - }) - } - } - - fn supports_native_tools(&self) -> bool { - true - } - - async fn effective_context_window(&self, _model: &str) -> Option { - None - } -} - -#[tokio::test] -async fn polling_tool_is_exempt_from_repeat_breakers() { - // #4230 (Codex P1): `wait_subagent` is contractually re-invoked with - // identical args while a task is still running. Five identical polls — past - // both REPEAT_CALL_THRESHOLD (3) and REPEAT_OUTPUT_THRESHOLD (4) — must NOT - // halt; the turn finishes normally once the poll returns a final result. - struct StillRunningToolSource { - specs: Vec, - } - #[async_trait] - impl ToolSource for StillRunningToolSource { - fn request_specs(&self) -> &[crate::openhuman::tools::ToolSpec] { - &self.specs - } - async fn execute_call( - &mut self, - _call: &ParsedToolCall, - _iteration: usize, - _progress: &dyn super::ProgressReporter, - _progress_call_id: &str, - ) -> ToolRunResult { - ToolRunResult { - text: "sub-agent is still running; call wait_subagent again".into(), - success: true, - } - } - } - - let provider = Arc::new(PollingProvider { - served: Mutex::new(0), - }); - let mut tool_source = StillRunningToolSource { specs: Vec::new() }; - let mut history = vec![ChatMessage::system("SYSTEM"), ChatMessage::user("TASK")]; - - let outcome = run_with_source(provider.as_ref(), &mut history, &mut tool_source).await; - - assert_eq!( - outcome.stop, - TurnStop::Final, - "a legitimate poll loop must finish normally, not trip a no-progress halt" - ); - assert!( - outcome.text.contains("DONE"), - "the final poll result should be returned: {}", - outcome.text - ); - assert_eq!( - outcome.iterations, 6, - "5 identical polls + 1 final response" - ); -} diff --git a/src/openhuman/agent/harness/engine/mod.rs b/src/openhuman/agent/harness/engine/mod.rs index 91ff5cc86..39a9e2f34 100644 --- a/src/openhuman/agent/harness/engine/mod.rs +++ b/src/openhuman/agent/harness/engine/mod.rs @@ -1,31 +1,14 @@ -//! Unified agent turn engine. +//! Shared agent-turn seams reused by the tinyagents harness route. //! -//! Historically the harness carried THREE near-identical agentic loops — one -//! per entry point (`Agent::turn` for web/desktop chat, `run_tool_call_loop` -//! for non-web channels + triage, and the subagent `run_inner_loop`). They each -//! re-implemented the same shape (call the LLM → parse tool calls → execute -//! tools → append results → repeat until final text or the iteration cap) and -//! had drifted in subtle ways. -//! -//! This module is the single home for the pieces those loops share, so they -//! can't drift again. The extraction is incremental (see the unify-agent-turn -//! plan): the first piece to land is [`tools::run_one_tool`] — the per-call -//! tool executor (policy gate → scope guard → approval gate → execute with -//! timeout → scrub/tokenjuice/cap/summarize → audit), which was previously -//! duplicated verbatim across all three loops. +//! The harness historically carried three near-identical agentic loops +//! (`Agent::turn`, `run_tool_call_loop`, the sub-agent `run_inner_loop`), all +//! retired in favour of the tinyagents harness (issue #4249). What survives here +//! are the cross-cutting pieces the tinyagents route still reuses: the +//! max-iteration [`CheckpointStrategy`] seam and the [`ProgressReporter`] / +//! [`TurnProgress`] sink that mirrors a turn's events onto `AgentProgress`. pub(crate) mod checkpoint; -pub(crate) mod core; -pub(crate) mod parser; pub(crate) mod progress; -pub(crate) mod state; -pub(crate) mod tool_source; -pub(crate) mod tools; -pub(crate) use checkpoint::{CheckpointOutcome, CheckpointStrategy, ErrorCheckpoint}; -pub(crate) use core::{run_turn_engine, TurnStop}; -pub(crate) use parser::{DefaultParser, DispatcherParser}; -pub(crate) use progress::{ProgressReporter, SubagentProgress, TurnProgress}; -pub(crate) use state::{NullObserver, TurnObserver}; -pub(crate) use tool_source::{RegistryToolSource, ToolSource}; -pub(crate) use tools::{run_one_tool, ToolRunResult}; +pub(crate) use checkpoint::{CheckpointOutcome, CheckpointStrategy}; +pub(crate) use progress::{ProgressReporter, TurnProgress}; diff --git a/src/openhuman/agent/harness/engine/parser.rs b/src/openhuman/agent/harness/engine/parser.rs deleted file mode 100644 index 05601b548..000000000 --- a/src/openhuman/agent/harness/engine/parser.rs +++ /dev/null @@ -1,70 +0,0 @@ -//! Response-parsing seam. -//! -//! The channel loop and subagent extract tool calls from a provider response -//! with the built-in native-first + XML-fallback logic ([`DefaultParser`]). -//! `Agent::turn` instead uses its configured [`ToolDispatcher`] (native / XML / -//! PFormat) — PFormat in particular parses positional `name[args]` calls the -//! built-in path can't. [`DispatcherParser`] adapts a dispatcher to this seam so -//! the engine stays parser-agnostic while preserving every dispatcher's grammar. -//! -//! `parse` returns `(display_text, calls)`: the narrative text to surface (tool -//! markup stripped) and the parsed calls in the engine's internal -//! [`ParsedToolCall`] shape. The engine keeps the *raw* response text -//! separately for assistant-history serialization. - -use crate::openhuman::agent::dispatcher::ToolDispatcher; -use crate::openhuman::agent::harness::parse::{ - parse_structured_tool_calls, parse_tool_calls, ParsedToolCall, -}; -use crate::openhuman::inference::provider::ChatResponse; - -pub(crate) trait ResponseParser: Send + Sync { - /// Returns `(display_text, calls)` for this provider response. - fn parse(&self, resp: &ChatResponse) -> (String, Vec); -} - -/// Built-in parser: prefer native structured tool calls, fall back to the -/// XML-tag parser over the response text. Used by the channel loop + subagent. -pub(crate) struct DefaultParser; - -impl ResponseParser for DefaultParser { - fn parse(&self, resp: &ChatResponse) -> (String, Vec) { - let response_text = resp.text_or_empty().to_string(); - let mut calls = parse_structured_tool_calls(&resp.tool_calls); - let mut parsed_text = String::new(); - if calls.is_empty() { - let (fallback_text, fallback_calls) = parse_tool_calls(&response_text); - if !fallback_text.is_empty() { - parsed_text = fallback_text; - } - calls = fallback_calls; - } - let display_text = if parsed_text.is_empty() { - response_text - } else { - parsed_text - }; - (display_text, calls) - } -} - -/// Adapts an [`Agent`]'s configured [`ToolDispatcher`] to the parser seam, -/// converting the dispatcher's `ParsedToolCall` shape into the engine's. -pub(crate) struct DispatcherParser<'a> { - pub dispatcher: &'a dyn ToolDispatcher, -} - -impl ResponseParser for DispatcherParser<'_> { - fn parse(&self, resp: &ChatResponse) -> (String, Vec) { - let (text, calls) = self.dispatcher.parse_response(resp); - let calls = calls - .into_iter() - .map(|c| ParsedToolCall { - name: c.name, - arguments: c.arguments, - id: c.tool_call_id, - }) - .collect(); - (text, calls) - } -} diff --git a/src/openhuman/agent/harness/engine/progress.rs b/src/openhuman/agent/harness/engine/progress.rs index 2cb5982a9..da0388824 100644 --- a/src/openhuman/agent/harness/engine/progress.rs +++ b/src/openhuman/agent/harness/engine/progress.rs @@ -199,133 +199,6 @@ impl ProgressReporter for TurnProgress { } } -/// Sub-agent flavor: `Subagent*` lifecycle + `SubagentToolCall*`, no streaming. -pub(crate) struct SubagentProgress { - pub sink: Option>, - pub agent_id: String, - pub task_id: String, - pub extended_policy: bool, -} - -#[async_trait] -impl ProgressReporter for SubagentProgress { - async fn iteration_started(&self, iteration: u32, max_iterations: u32) { - if let Some(ref sink) = self.sink { - emit( - sink, - AgentProgress::SubagentIterationStarted { - agent_id: self.agent_id.clone(), - task_id: self.task_id.clone(), - iteration, - max_iterations, - extended_policy: self.extended_policy, - }, - ); - } - } - - #[allow(clippy::too_many_arguments)] - async fn tool_started( - &self, - call_id: &str, - tool_name: &str, - arguments: &serde_json::Value, - iteration: u32, - display_label: Option<&str>, - display_detail: Option<&str>, - ) { - if let Some(ref sink) = self.sink { - emit( - sink, - AgentProgress::SubagentToolCallStarted { - agent_id: self.agent_id.clone(), - task_id: self.task_id.clone(), - call_id: call_id.to_string(), - tool_name: tool_name.to_string(), - arguments: arguments.clone(), - iteration, - display_label: display_label.map(str::to_string), - display_detail: display_detail.map(str::to_string), - }, - ); - } - } - - async fn tool_completed( - &self, - call_id: &str, - tool_name: &str, - success: bool, - output: &str, - elapsed_ms: u64, - iteration: u32, - ) { - if let Some(ref sink) = self.sink { - emit( - sink, - AgentProgress::SubagentToolCallCompleted { - agent_id: self.agent_id.clone(), - task_id: self.task_id.clone(), - call_id: call_id.to_string(), - tool_name: tool_name.to_string(), - success, - output_chars: output.chars().count(), - output: output.to_string(), - elapsed_ms, - iteration, - }, - ); - } - } - - /// Stream the child's visible text + reasoning deltas to the parent, - /// attributed to this sub-agent's `task_id` so the UI renders them inside - /// the live subagent row (PR #3007). Tool-call arg fragments are dropped - /// here — they're already surfaced via the `SubagentToolCall*` lifecycle - /// events, so forwarding them too would double-render. - fn make_stream_sink( - &self, - iteration: u32, - ) -> ( - Option>, - Option>, - ) { - let Some(sink) = self.sink.clone() else { - return (None, None); - }; - let agent_id = self.agent_id.clone(); - let task_id = self.task_id.clone(); - let (tx, mut rx) = tokio::sync::mpsc::channel::(128); - let forwarder = tokio::spawn(async move { - while let Some(event) = rx.recv().await { - let mapped = match event { - ProviderDelta::TextDelta { delta } => AgentProgress::SubagentTextDelta { - agent_id: agent_id.clone(), - task_id: task_id.clone(), - delta, - iteration, - }, - ProviderDelta::ThinkingDelta { delta } => { - AgentProgress::SubagentThinkingDelta { - agent_id: agent_id.clone(), - task_id: task_id.clone(), - delta, - iteration, - } - } - ProviderDelta::ToolCallStart { .. } - | ProviderDelta::ToolCallArgsDelta { .. } => continue, - }; - // Await backpressure so streamed deltas arrive in order. - if sink.send(mapped).await.is_err() { - break; - } - } - }); - (Some(tx), Some(forwarder)) - } -} - /// No-op reporter for triage / tests. pub(crate) struct NullProgress; diff --git a/src/openhuman/agent/harness/engine/state.rs b/src/openhuman/agent/harness/engine/state.rs deleted file mode 100644 index 1c4839df8..000000000 --- a/src/openhuman/agent/harness/engine/state.rs +++ /dev/null @@ -1,98 +0,0 @@ -//! Turn-state observer seam. -//! -//! The engine drives the loop over a `Vec` working buffer, but the -//! three callers want to *do* different things around each step: -//! -//! * the channel loop wants nothing extra ([`NullObserver`]); -//! * the subagent wants per-iteration transcript persistence, usage -//! accumulation, and worker-thread mirroring (assistant intents, per-call -//! results, batched text-mode results, final response); -//! * `Agent::turn` wants its `ContextManager` reduction before each dispatch, -//! transcript persistence, and per-turn usage/cost snapshots. -//! -//! [`TurnObserver`] is the seam: every method defaults to a no-op, so an impl -//! only overrides the hooks its caller needs. The engine still owns the -//! universal concerns (stop hooks, context guard, token-budget trim, the -//! circuit breaker) inline — the observer is for caller-specific side effects. - -use anyhow::Result; -use async_trait::async_trait; - -use super::tool_source::ToolSource; -use crate::openhuman::agent::harness::parse::ParsedToolCall; -use crate::openhuman::inference::provider::{ChatMessage, ToolCall, UsageInfo}; - -#[async_trait] -pub(crate) trait TurnObserver: Send { - /// Called before each provider dispatch, after the engine's own context - /// guard + token-budget trim. `Agent::turn` runs its `ContextManager` - /// reduction chain here. Default: no-op. - async fn before_dispatch( - &mut self, - _history: &mut Vec, - _tools: &mut dyn ToolSource, - _iteration: usize, - ) -> Result<()> { - Ok(()) - } - - /// Called once per provider response that carried a usage block, so the - /// caller can accumulate its own token tally / transcript usage snapshot. - fn record_usage(&mut self, _provider: &str, _model: &str, _usage: &UsageInfo) {} - - /// Called after the assistant message for this iteration is committed to - /// the engine's working buffer. `response_text` is the raw provider text - /// (pre native serialization); `reasoning_content` is the thinking-model - /// content to round-trip; `native_tool_calls` are the provider's structured - /// calls (empty in text/prompt mode); `parsed_calls` are the engine-parsed - /// calls (empty when `is_final`). `Agent::turn` uses these to rebuild its - /// typed `ConversationMessage` history; the subagent mirrors to its worker - /// thread. - #[allow(clippy::too_many_arguments)] - async fn on_assistant( - &mut self, - _display_text: &str, - _response_text: &str, - _reasoning_content: Option<&str>, - _native_tool_calls: &[ToolCall], - _parsed_calls: &[ParsedToolCall], - _iteration: usize, - _is_final: bool, - ) { - } - - /// Called after one tool's result is known, in native-tool mode (one - /// `role:tool` message per call). Subagent mirrors per-call results to its - /// worker thread; `Agent::turn` buffers them to rebuild typed history. - fn on_tool_result( - &mut self, - _call_id: &str, - _tool_name: &str, - _result_text: &str, - _success: bool, - _iteration: usize, - ) { - } - - /// Called after a batched `[Tool results]` user message is committed - /// (text/prompt mode, where there are no per-call `role:tool` messages). - fn on_results_batch(&mut self, _content: &str, _iteration: usize) {} - - /// Called after the iteration's history is finalized (the transcript - /// persistence point) — both after the final response and after each tool - /// round's results are appended. - fn after_iteration(&mut self, _history: &[ChatMessage], _iteration: usize) {} - - /// Whether an empty final response (no text, no tool calls) is acceptable. - /// The channel/subagent loops return it as `Ok("")`; `Agent::turn` treats - /// it as a degenerate/poisoned completion and surfaces an error instead of - /// a silent blank reply (bug-report-2026-05-26 A1). Default: allowed. - fn allow_empty_final(&self) -> bool { - true - } -} - -/// No-op observer for the channel/CLI/triage loop, which keeps no extra state. -pub(crate) struct NullObserver; - -impl TurnObserver for NullObserver {} diff --git a/src/openhuman/agent/harness/engine/tool_source.rs b/src/openhuman/agent/harness/engine/tool_source.rs deleted file mode 100644 index 5e84a347c..000000000 --- a/src/openhuman/agent/harness/engine/tool_source.rs +++ /dev/null @@ -1,150 +0,0 @@ -//! Tool sourcing seam for the turn engine. -//! -//! The three former loops resolved "what tools can the model call this turn and -//! how do I execute one" differently: -//! -//! * the channel loop advertised `registry + extra` filtered by a visibility -//! whitelist, and executed via the shared [`run_one_tool`]; -//! * the subagent loop advertised a definition-filtered slice of the parent's -//! tools (with lazy toolkit registration), and had its own per-call body; -//! * `Agent::turn` advertised `Agent.visible_tool_specs` and executed via the -//! richer `Agent::execute_tool_call` (session policy + per-call permission -//! levels + `execute_with_options`). -//! -//! [`ToolSource`] is the single seam the engine talks to: it advertises the -//! request specs and owns per-call execution (including the start/complete -//! progress events). [`RegistryToolSource`] is the channel/CLI/triage impl; the -//! subagent and `Agent` impls land in later phases. - -use std::collections::HashSet; -use std::sync::Arc; - -use async_trait::async_trait; - -use super::super::payload_summarizer::PayloadSummarizer; -use super::progress::ProgressReporter; -use super::{run_one_tool, ToolRunResult}; -use crate::openhuman::agent::harness::parse::ParsedToolCall; -use crate::openhuman::agent_tool_policy::ToolPolicySession; -use crate::openhuman::tools::policy::ToolPolicy; -use crate::openhuman::tools::{Tool, ToolSpec}; - -/// What the engine needs from "the set of tools available this turn". -#[async_trait] -pub(crate) trait ToolSource: Send { - /// The deduped, visibility-filtered specs to advertise to the provider - /// this turn. Re-read each iteration so impls that register tools lazily - /// (subagent toolkit resolution) can grow the advertised set over a turn. - fn request_specs(&self) -> &[ToolSpec]; - - /// Execute one parsed tool call end-to-end, emitting its `ToolCallStarted` - /// / `ToolCallCompleted` (or flavor-equivalent) progress events. Returns a - /// [`ToolRunResult`] the engine folds into history + the circuit breaker. - async fn execute_call( - &mut self, - call: &ParsedToolCall, - iteration: usize, - progress: &dyn ProgressReporter, - progress_call_id: &str, - ) -> ToolRunResult; - - /// Replace the caller-specific runtime snapshot after a dynamic refresh. - /// Default no-op for non-agent callers. - #[allow(clippy::too_many_arguments)] - fn sync_agent_surface( - &mut self, - _tools: Arc>>, - _visible_tool_names: HashSet, - _tool_policy_session: ToolPolicySession, - _payload_summarizer: Option>, - _prefer_markdown: bool, - _budget_bytes: usize, - _should_send_specs: bool, - _advertised_specs: Vec, - ) { - } -} - -/// The channel/CLI/triage tool source: a persistent `registry`, optional -/// per-turn synthesised `extra` tools, an optional visibility whitelist, and a -/// pluggable [`ToolPolicy`]. Mirrors the original `run_tool_call_loop` tool -/// plumbing exactly. -pub(crate) struct RegistryToolSource<'a> { - registry: &'a [Box], - extra: &'a [Box], - visible: Option<&'a HashSet>, - tool_policy: &'a dyn ToolPolicy, - payload_summarizer: Option<&'a dyn PayloadSummarizer>, - specs: Vec, -} - -impl<'a> RegistryToolSource<'a> { - pub(crate) fn new( - registry: &'a [Box], - extra: &'a [Box], - visible: Option<&'a HashSet>, - tool_policy: &'a dyn ToolPolicy, - payload_summarizer: Option<&'a dyn PayloadSummarizer>, - ) -> Self { - // Filter to visible tools, then dedup by name before sending to the - // provider. Registry tools may collide with per-turn synthesised - // extra_tools (e.g. an `ArchetypeDelegationTool` whose - // `delegate_name = "research"` shadowing a same-named skill). Some - // providers 400 on duplicate tool names — see TAURI-RUST-4. - let filtered: Vec = registry - .iter() - .chain(extra.iter()) - .filter(|tool| visible.map(|s| s.contains(tool.name())).unwrap_or(true)) - .map(|tool| tool.spec()) - .collect(); - let specs = crate::openhuman::agent::harness::session::dedup_visible_tool_specs(filtered); - Self { - registry, - extra, - visible, - tool_policy, - payload_summarizer, - specs, - } - } - - fn is_visible(&self, name: &str) -> bool { - self.visible.map(|s| s.contains(name)).unwrap_or(true) - } -} - -#[async_trait] -impl ToolSource for RegistryToolSource<'_> { - fn request_specs(&self) -> &[ToolSpec] { - &self.specs - } - - async fn execute_call( - &mut self, - call: &ParsedToolCall, - iteration: usize, - progress: &dyn ProgressReporter, - progress_call_id: &str, - ) -> ToolRunResult { - // Look up the tool by name in the combined registry + extras, subject - // to the visibility whitelist. A hallucinated / filtered-out name - // resolves to `None`, which `run_one_tool` reports as an unknown tool. - let tool_opt: Option<&dyn Tool> = self - .registry - .iter() - .chain(self.extra.iter()) - .find(|t| t.name() == call.name && self.is_visible(t.name())) - .map(|b| b.as_ref()); - run_one_tool( - tool_opt, - call, - iteration, - progress, - self.tool_policy, - self.payload_summarizer, - progress_call_id, - crate::openhuman::tokenjuice::AgentTokenjuiceCompression::Full, - ) - .await - } -} diff --git a/src/openhuman/agent/harness/engine/tools.rs b/src/openhuman/agent/harness/engine/tools.rs deleted file mode 100644 index e11bd727a..000000000 --- a/src/openhuman/agent/harness/engine/tools.rs +++ /dev/null @@ -1,537 +0,0 @@ -//! Shared per-call tool executor. -//! -//! [`run_one_tool`] runs the full lifecycle of a single parsed tool call: -//! -//! 1. emit `ToolCallStarted` (for *every* call, including ones rejected below, -//! so a client row created from streamed args always gets a terminal event); -//! 2. evaluate the pluggable [`ToolPolicy`] (deny short-circuits everything, -//! including approval side-effects); -//! 3. guard `CliRpcOnly` scope (such tools can't run in the autonomous loop); -//! 4. route external-effect tools through the process-global `ApprovalGate`; -//! 5. execute with the configured timeout, then scrub credentials, apply -//! tokenjuice, the per-tool size cap, and the optional payload summarizer; -//! 6. stamp the approval audit "after" row (#2135); -//! 7. emit `ToolCallCompleted`. -//! -//! It returns a [`ToolRunResult`] (`text` + `success`). The caller owns history -//! shaping (native `role:tool` messages vs XML `` blocks) and the -//! repeated-failure circuit breaker, both of which it drives uniformly from the -//! returned `success`/`text` regardless of which branch produced them. -//! -//! This body was lifted verbatim (behavior-preserving) from the canonical -//! `run_tool_call_loop` in `tool_loop.rs`; the three loops now call it instead -//! of each carrying their own copy. - -use super::super::payload_summarizer::PayloadSummarizer; -use super::progress::ProgressReporter; -use crate::openhuman::agent::harness::parse::ParsedToolCall; -use crate::openhuman::tools::policy::{PolicyDecision, ToolPolicy}; -use crate::openhuman::tools::traits::ToolScope; -use crate::openhuman::tools::Tool; - -use super::super::credentials::scrub_credentials; - -/// Outcome of a single tool call. `text` is what should be fed back to the -/// model (a result body, an error, or a denial reason); `success` is `false` -/// for any non-OK outcome (policy/approval denial, scope rejection, timeout, -/// tool error, unknown tool) so the caller's circuit breaker and history -/// formatting can treat every failure mode uniformly. -pub(crate) struct ToolRunResult { - pub text: String, - pub success: bool, -} - -/// Grace added to the outer harness deadline when a tool enforces its **own** -/// per-call timeout internally (a `Secs` policy). The tool's own timeout then -/// fires first — it produces a more specific message and actually kills the -/// child process, whereas the harness backstop merely drops the future. -const TOOL_TIMEOUT_GRACE_SECS: u64 = 5; - -/// Map a [`ToolTimeout`] policy to `(deadline, effective_secs)` for -/// [`run_one_tool`]. `deadline` is `None` for an unbounded run (no harness -/// timeout); `effective_secs` is the value surfaced in the timeout message -/// (unused when `deadline` is `None`). -pub(crate) fn resolve_tool_deadline( - policy: crate::openhuman::tools::traits::ToolTimeout, -) -> (Option, u64) { - use crate::openhuman::tool_timeout::{MAX_TIMEOUT_SECS, MIN_TIMEOUT_SECS}; - use crate::openhuman::tools::traits::ToolTimeout; - match policy { - ToolTimeout::Inherit => { - let s = crate::openhuman::tool_timeout::tool_execution_timeout_secs(); - (Some(std::time::Duration::from_secs(s)), s) - } - ToolTimeout::Secs(req) => { - let s = req.clamp(MIN_TIMEOUT_SECS, MAX_TIMEOUT_SECS); - ( - Some(std::time::Duration::from_secs( - s.saturating_add(TOOL_TIMEOUT_GRACE_SECS), - )), - s, - ) - } - ToolTimeout::Unbounded => (None, 0), - } -} - -/// Execute one parsed tool call end-to-end. See the module docs for the full -/// lifecycle. `tool_opt` is the (already visibility-filtered) tool the caller -/// resolved by name — `None` means the model requested an unknown/filtered-out -/// tool, which is reported as a structured error the LLM can correct next turn. -/// -/// `progress_call_id` is the stable id threaded through the start/complete -/// event pair (and any preceding args-delta events) so consumers can reconcile -/// tool rows by id. -pub(crate) async fn run_one_tool( - tool_opt: Option<&dyn Tool>, - call: &ParsedToolCall, - iteration: usize, - progress: &dyn ProgressReporter, - tool_policy: &dyn ToolPolicy, - payload_summarizer: Option<&dyn PayloadSummarizer>, - progress_call_id: &str, - tokenjuice_compression: crate::openhuman::tokenjuice::AgentTokenjuiceCompression, -) -> ToolRunResult { - let iteration_u32 = (iteration + 1) as u32; - - // Compute the human label + contextual detail once, server-side, from the - // resolved tool (covers dynamic Composio/MCP/integration tools the client - // can't know). `None` for an unknown/filtered-out tool → the client - // formatter falls back to its own labels. - let (display_label, display_detail) = match tool_opt { - Some(tool) => ( - tool.display_label(&call.arguments), - tool.display_detail(&call.arguments), - ), - None => (None, None), - }; - - // Emit a "tool started" event for every parsed call, even ones that will be - // rejected below (approval denied, CliRpcOnly, unknown) — the client-side - // row was created from the streamed args and needs a terminal event. - progress - .tool_started( - progress_call_id, - &call.name, - &call.arguments, - iteration_u32, - display_label.as_deref(), - display_detail.as_deref(), - ) - .await; - - // Helper: emit a failed "tool completed" event for an early-exit path - // (denied / CliRpcOnly / unknown) so the client row flips to `error` - // instead of staying running. - let emit_failed_completion = |message: &str| { - let message = message.to_string(); - async move { - progress - .tool_completed( - progress_call_id, - &call.name, - false, - &message, - 0, - iteration_u32, - ) - .await; - } - }; - - // ── Tool policy check (#2131) ───────────────── - // Evaluate the pluggable ToolPolicy before any approval or execution. If - // the policy denies the call, skip everything (including approval - // side-effects) and return the denial reason as a tool error to the model. - if let PolicyDecision::Deny(reason) = tool_policy.evaluate(&call.name, &call.arguments) { - tracing::debug!( - iteration, - tool = call.name.as_str(), - reason = %reason, - "[agent_loop] tool policy denied tool call" - ); - let denied = format!("Tool '{}' denied by policy: {reason}", call.name); - emit_failed_completion(&denied).await; - return ToolRunResult { - text: denied, - success: false, - }; - } - - let Some(tool) = tool_opt else { - tracing::warn!( - iteration, - tool = call.name.as_str(), - "[agent_loop] unknown tool requested" - ); - let msg = format!("Unknown tool: {}", call.name); - emit_failed_completion(&msg).await; - return ToolRunResult { - text: msg, - success: false, - }; - }; - - tracing::debug!( - iteration, - tool = call.name.as_str(), - found = true, - "[agent_loop] executing tool" - ); - - // Scope check: CliRpcOnly tools cannot run in the autonomous agent loop. - if tool.scope() == ToolScope::CliRpcOnly { - tracing::warn!( - iteration, - tool = call.name.as_str(), - "[agent_loop] tool scope is CliRpcOnly — denied in agent loop" - ); - let denied = format!( - "Tool '{}' is only available via explicit CLI/RPC invocation, not in the autonomous agent loop.", - call.name - ); - emit_failed_completion(&denied).await; - return ToolRunResult { - text: denied, - success: false, - }; - } - - // ── External-effect approval gate (#1339, #2135) ── - // Tools whose `external_effect()` returns true route through the - // process-global `ApprovalGate` so the UI can prompt the user before - // `execute()` runs. The gate is `None` when supervised mode is disabled or - // in test envs — behavior matches the pre-#1339 path. - // - // `approval_request_id` carries the persisted row id forward so we can - // stamp the terminal execution outcome onto the same `pending_approvals` - // row after the tool finishes (issue #2135). `None` means the tool was - // either not gated, was session-allowlist-shortcutted, or was denied — - // none of which produce an audit row that needs an "after" entry. - let mut approval_request_id: Option = None; - let mut approval_gate_for_audit: Option< - std::sync::Arc, - > = None; - if tool.external_effect_with_args(&call.arguments) { - if let Some(gate) = crate::openhuman::approval::ApprovalGate::try_global() { - let summary = crate::openhuman::approval::summarize_action(&call.name, &call.arguments); - let redacted = crate::openhuman::approval::redact_args(&call.arguments); - let (outcome, request_id) = - gate.intercept_audited(&call.name, &summary, redacted).await; - match outcome { - crate::openhuman::approval::GateOutcome::Allow => { - approval_request_id = request_id; - if approval_request_id.is_some() { - approval_gate_for_audit = Some(gate); - } - } - crate::openhuman::approval::GateOutcome::Deny { reason } => { - tracing::warn!( - iteration, - tool = call.name.as_str(), - reason = %reason, - "[agent_loop] approval gate denied tool call" - ); - emit_failed_completion(&reason).await; - return ToolRunResult { - text: reason, - success: false, - }; - } - } - } - } - - // A tool that exposes a caller-supplied per-call budget (e.g. `shell`'s - // `timeout_secs`) raises/lowers the outer harness deadline to match; the - // resolver bounds it and falls back to the global config when absent or - // out of range. Without this, the global cap would kill a long-running - // command before the tool's own per-call timeout could take effect. - // Resolve this call's wall-clock policy. Most tools `Inherit` the global, - // config-driven timeout; scripting tools (`shell`/`node_exec`/`npm_exec`) - // run `Unbounded` unless the caller passed an explicit `timeout_secs` - // (`Secs`). See issue #4023 — a long-but-legitimate script must not be - // hard-killed by a default cap. - let policy = tool.timeout_policy(&call.arguments); - let (tool_deadline, timeout_secs) = resolve_tool_deadline(policy); - match policy { - crate::openhuman::tools::traits::ToolTimeout::Secs(req) => tracing::debug!( - iteration, - tool = call.name.as_str(), - requested_timeout_secs = req, - effective_timeout_secs = timeout_secs, - "[agent_loop] tool requested an explicit per-call timeout" - ), - crate::openhuman::tools::traits::ToolTimeout::Unbounded => tracing::debug!( - iteration, - tool = call.name.as_str(), - "[agent_loop] tool runs unbounded (no harness timeout) — no explicit timeout_secs" - ), - crate::openhuman::tools::traits::ToolTimeout::Inherit => {} - } - let tool_started = std::time::Instant::now(); - let outcome = match tool_deadline { - Some(deadline) => { - tokio::time::timeout(deadline, tool.execute(call.arguments.clone())).await - } - // Unbounded: run to completion with no harness-imposed deadline. - None => Ok(tool.execute(call.arguments.clone()).await), - }; - let elapsed_ms = tool_started.elapsed().as_millis() as u64; - let (result_text, success) = match outcome { - Ok(Ok(r)) => { - let output = r.output(); - let success = !r.is_error; - if success { - tracing::debug!( - iteration, - tool = call.name.as_str(), - output_len = output.len(), - "[agent_loop] tool succeeded" - ); - let mut scrubbed = scrub_credentials(&output); - let (compacted, tj_stats) = - crate::openhuman::tokenjuice::compact_tool_output_with_policy( - &call.name, - Some(&call.arguments), - &scrubbed, - Some(0), - tokenjuice_compression, - ) - .await; - if tj_stats.applied { - log::debug!( - "[agent_loop] tokenjuice applied tool={} rule={} {}->{} bytes", - call.name, - tj_stats.rule_id, - tj_stats.original_bytes, - tj_stats.compacted_bytes - ); - scrubbed = compacted; - } - - // Per-tool max_result_size_chars cap. When a tool sets it and - // the (post-tokenjuice) body still exceeds the cap, truncate - // here and skip the global payload summarizer for this call — - // the cap is fast and deterministic, the summarizer is the - // fallback for tools that don't know their own size budget. - let mut hit_per_tool_cap = false; - if let Some(cap) = tool.max_result_size_chars() { - let char_count = scrubbed.chars().count(); - if char_count > cap { - let truncated: String = scrubbed.chars().take(cap).collect(); - let dropped = char_count - cap; - log::info!( - "[agent_loop] per-tool cap applied tool={} cap_chars={} original_chars={} dropped_chars={}", - call.name, - cap, - char_count, - dropped, - ); - scrubbed = format!( - "{truncated}\n\n[truncated by tool cap: {dropped} more chars not shown]" - ); - hit_per_tool_cap = true; - } - } - - if !hit_per_tool_cap { - if let Some(summarizer) = payload_summarizer { - log::debug!( - "[agent_loop] payload_summarizer intercepting tool={} bytes={}", - call.name, - scrubbed.len() - ); - match summarizer - .maybe_summarize(&call.name, None, &scrubbed) - .await - { - Ok(Some(payload)) => { - log::info!( - "[agent_loop] payload_summarizer compressed tool={} {}->{} bytes", - call.name, - payload.original_bytes, - payload.summary_bytes - ); - scrubbed = payload.summary; - } - Ok(None) => { - log::debug!( - "[agent_loop] payload_summarizer pass-through tool={} bytes={}", - call.name, - scrubbed.len() - ); - } - Err(e) => { - log::warn!( - "[agent_loop] payload_summarizer error tool={} err={} (passing raw payload through)", - call.name, - e - ); - } - } - } - } - (scrubbed, true) - } else { - // Scrub before logging — a failing tool payload can carry - // credentials / PII, so never log the raw output. - let scrubbed = scrub_credentials(&output); - tracing::warn!( - iteration, - tool = call.name.as_str(), - "[agent_loop] tool returned error: {scrubbed}" - ); - let (compacted, _) = crate::openhuman::tokenjuice::compact_tool_output_with_policy( - &call.name, - Some(&call.arguments), - &scrubbed, - Some(1), - tokenjuice_compression, - ) - .await; - (format!("Error: {compacted}"), false) - } - } - Ok(Err(e)) => { - // Route through `report_error_or_expected` (not the unconditional - // `report_error`) so an error a downstream layer already classified - // as expected user-state isn't re-reported as a hard Sentry event - // here. The integrations client (`integrations/client.rs`) already - // demotes its backend 4xx/auth-state failures via - // `report_error_or_expected`, but it ALSO bubbles the error up; it - // lands in this `Ok(Err(_))` arm and was being re-reported as a - // hard `tool`/`execute` event — the double-report behind Sentry - // TAURI-RUST-84E (`Backend returned 401 Unauthorized for POST - // .../agent-integrations/parallel/search: Invalid token`, a - // user-end invalid/expired session token with no openhuman-side - // lever). Genuine tool failures don't match any classifier arm and - // still surface as hard errors — only already-classified - // user-state errors are demoted to a warn/info breadcrumb. - crate::core::observability::report_error_or_expected( - format!("{e:#}").as_str(), - "tool", - "execute", - &[ - ("tool", call.name.as_str()), - ("outcome", "failed"), - ("iteration", &(iteration + 1).to_string()), - ], - ); - (format!("Error executing {}: {e}", call.name), false) - } - Err(_) => { - let msg = format!( - "tool '{}' timed out after {} seconds", - call.name, timeout_secs - ); - crate::core::observability::report_error( - msg.as_str(), - "tool", - "execute", - &[ - ("tool", call.name.as_str()), - ("outcome", "timeout"), - ("timeout_secs", &timeout_secs.to_string()), - ("iteration", &(iteration + 1).to_string()), - ], - ); - ( - format!( - "Error: tool '{}' timed out after {} seconds", - call.name, timeout_secs - ), - false, - ) - } - }; - progress - .tool_completed( - progress_call_id, - &call.name, - success, - &result_text, - elapsed_ms, - iteration_u32, - ) - .await; - // ── Approval audit after-action row (#2135) ──── - // Stamp the terminal status onto the same `pending_approvals` row the gate - // created before execution, so the audit trail carries both the before - // (approval) and after (executed_at + outcome). Best-effort: a write - // failure here is logged but not propagated to the agent. - if let (Some(gate), Some(req_id)) = ( - approval_gate_for_audit.as_ref(), - approval_request_id.as_ref(), - ) { - let exec_outcome = if success { - crate::openhuman::approval::ExecutionOutcome::Success - } else { - crate::openhuman::approval::ExecutionOutcome::Failure - }; - let err_text = if success { - None - } else { - Some(result_text.as_str()) - }; - gate.record_execution(req_id, exec_outcome, err_text); - } - - ToolRunResult { - text: result_text, - success, - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::openhuman::tools::traits::ToolTimeout; - - #[test] - fn unbounded_policy_imposes_no_deadline() { - let (deadline, _secs) = resolve_tool_deadline(ToolTimeout::Unbounded); - assert!( - deadline.is_none(), - "scripting tools with no explicit timeout must run unbounded" - ); - } - - #[test] - fn inherited_policy_uses_shared_deadline() { - let (deadline, secs) = resolve_tool_deadline(ToolTimeout::Inherit); - assert!(secs >= crate::openhuman::tool_timeout::MIN_TIMEOUT_SECS); - assert_eq!(deadline, Some(std::time::Duration::from_secs(secs))); - } - - #[test] - fn explicit_secs_policy_adds_grace_and_reports_value() { - let (deadline, secs) = resolve_tool_deadline(ToolTimeout::Secs(300)); - // Reported value is the requested budget; the outer deadline gets a - // grace margin so the tool's own timeout fires first. - assert_eq!(secs, 300); - assert_eq!( - deadline, - Some(std::time::Duration::from_secs( - 300 + TOOL_TIMEOUT_GRACE_SECS - )) - ); - } - - #[test] - fn explicit_secs_policy_clamps_out_of_range() { - // Above MAX clamps down; the deadline is built from the clamped value. - let (deadline, secs) = resolve_tool_deadline(ToolTimeout::Secs(999_999)); - assert_eq!(secs, crate::openhuman::tool_timeout::MAX_TIMEOUT_SECS); - assert_eq!( - deadline, - Some(std::time::Duration::from_secs( - crate::openhuman::tool_timeout::MAX_TIMEOUT_SECS + TOOL_TIMEOUT_GRACE_SECS - )) - ); - - // Below MIN clamps up to MIN. - let (_d, secs_min) = resolve_tool_deadline(ToolTimeout::Secs(0)); - assert_eq!(secs_min, crate::openhuman::tool_timeout::MIN_TIMEOUT_SECS); - } -} diff --git a/src/openhuman/agent/harness/fork_context.rs b/src/openhuman/agent/harness/fork_context.rs index db2b09eb7..568386c1b 100644 --- a/src/openhuman/agent/harness/fork_context.rs +++ b/src/openhuman/agent/harness/fork_context.rs @@ -155,7 +155,13 @@ tokio::task_local! { /// Context-preparation sources that already ran for this parent turn. /// Tools such as `agent_prepare_context` use this to avoid spawning a /// second context scout after the harness has already prepared context. - pub static AGENT_CONTEXT_PREPARED_SOURCES: Arc>; + /// + /// Behind an `Arc>` (not a plain `Arc>`) so a source can be + /// **appended live** mid-turn — the graph's `SuperContextMiddleware` runs its + /// scout during the harness run (after the initial list is scoped) and + /// registers its source via [`push_agent_context_prepared_source`] so a later + /// `agent_prepare_context` call in the same turn still self-suppresses. + pub static AGENT_CONTEXT_PREPARED_SOURCES: Arc>>; } /// Returns a clone of the current parent execution context, if one is set. @@ -175,15 +181,30 @@ where } /// Returns the one-shot context-preparation sources that have already run for -/// the current parent turn. +/// the current parent turn (a snapshot of the live list). pub fn current_agent_context_prepared_sources() -> Vec { AGENT_CONTEXT_PREPARED_SOURCES - .try_with(|sources| sources.as_ref().clone()) + .try_with(|sources| sources.lock().map(|s| s.clone()).unwrap_or_default()) .unwrap_or_default() } +/// Append a source to the current turn's prepared-context list, live. +/// +/// Used by the graph's `SuperContextMiddleware`, which prepares context *during* +/// the harness run (after [`with_agent_context_prepared_sources`] scoped the +/// initial list) so a later `agent_prepare_context` tool call in the same turn +/// observes it and self-suppresses. No-op outside an agent turn. +pub fn push_agent_context_prepared_source(source: AgentContextPreparedSource) { + let _ = AGENT_CONTEXT_PREPARED_SOURCES.try_with(|sources| { + if let Ok(mut guard) = sources.lock() { + guard.push(source); + } + }); +} + /// Run `future` with the current turn's already-prepared context sources -/// installed. +/// installed. The list is appendable mid-turn via +/// [`push_agent_context_prepared_source`]. pub async fn with_agent_context_prepared_sources( sources: Vec, future: F, @@ -192,6 +213,6 @@ where F: std::future::Future, { AGENT_CONTEXT_PREPARED_SOURCES - .scope(Arc::new(sources), future) + .scope(Arc::new(std::sync::Mutex::new(sources)), future) .await } diff --git a/src/openhuman/agent/harness/graph.rs b/src/openhuman/agent/harness/graph.rs new file mode 100644 index 000000000..72617733b --- /dev/null +++ b/src/openhuman/agent/harness/graph.rs @@ -0,0 +1,246 @@ +//! The **channel/CLI turn graph** (issue #4249). +//! +//! Per the per-folder `graph.rs` convention, this is the harness's top-level +//! (channel/CLI) graph definition, its available tools, and its summarization +//! step — all thin over the shared tinyagents seam +//! ([`run_turn_via_tinyagents_shared`]). +//! +//! **Graph.** A single agent-loop turn driven by the tinyagents harness (the +//! canonical channel/CLI path; the legacy `run_tool_call_loop` is removed), +//! covering the loop's control-flow seams (iteration cap, circuit breakers, stop +//! hooks). When the caller supplies an `on_progress` sender the harness event +//! stream is mirrored onto `AgentProgress` (live tool timeline, streaming text +//! deltas, cost/token footer) via the same +//! [`OpenhumanEventBridge`](crate::openhuman::tinyagents::OpenhumanEventBridge) +//! the chat route uses. +//! +//! **Available tools.** Reuses the bus handler's `Arc`-shared tool sets +//! (`tools_registry: Arc>>` + per-turn `extra_tools`), +//! advertised via [`SharedToolAdapter`](crate::openhuman::tinyagents::SharedToolAdapter) +//! and filtered by `visible_tool_names`. No early-exit tools on this path. +//! +//! **Summarization.** [`run_channel_turn_via_graph`] resolves the model's +//! effective context window before dispatch so the shared seam runs the +//! context-window summarization step (`tinyagents::summarize`) ahead of the +//! deterministic front-trim. + +use std::collections::HashSet; +use std::sync::Arc; + +use anyhow::Result; +use tokio::sync::mpsc::Sender; + +use crate::openhuman::agent::progress::AgentProgress; +use crate::openhuman::config::{MultimodalConfig, MultimodalFileConfig}; +use crate::openhuman::inference::provider::{ChatMessage, Provider}; +use crate::openhuman::tinyagents::run_turn_via_tinyagents_shared; +use crate::openhuman::tools::Tool; + +/// Drive a channel/CLI turn on the graph engine. Returns the final assistant +/// text. When `on_progress` is `Some`, the run streams and mirrors progress +/// onto `AgentProgress`; pass `None` for a fire-and-forget final-text turn. +#[allow(clippy::too_many_arguments)] +pub(crate) async fn run_channel_turn_via_graph( + provider: Arc, + history: &mut Vec, + tools_registry: Arc>>, + extra_tools: Vec>, + visible_tool_names: Option<&HashSet>, + model: &str, + temperature: f64, + max_iterations: usize, + multimodal: MultimodalConfig, + multimodal_files: MultimodalFileConfig, + on_progress: Option>, +) -> Result { + let extra_arc = Arc::new(extra_tools); + + // The callable set is the visibility whitelist (empty = every tool visible + // across the registry + per-turn extras). The runner advertises each via its + // own `spec()`, deduped by name (extras shadow the registry). + let allowed = visible_tool_names.cloned().unwrap_or_default(); + + // Capture native-tool support before `provider` is moved into the runner: the + // durable history append below serializes this turn's typed suffix with the + // matching dispatcher (native envelope vs prompt-guided text). + let native_tools = provider.supports_native_tools(); + + // Multimodal prep (parity with the chat route's + // `run_turn_via_tinyagents_session`, issue #4249): rehydrate image + // placeholders for vision-capable providers, then expand `[IMAGE:…]` / + // `[FILE:…]` markers into provider-ready content before dispatch. The + // expanded copy is provider-only — it is sent to the model but never + // persisted back into the channel `history` (see the reconstruction below). + let mut prepared = history.clone(); + if provider.supports_vision() + && crate::openhuman::agent::multimodal::has_image_placeholders(&prepared) + { + prepared = crate::openhuman::agent::multimodal::rehydrate_image_placeholders(&prepared); + } + let prepared = crate::openhuman::agent::multimodal::prepare_messages_for_provider( + &prepared, + &multimodal, + &multimodal_files, + ) + .await + .map(|prepared| prepared.messages) + .unwrap_or(prepared); + + // Resolve the provider's effective context window so the harness can run the + // context-window summarization step (issue #4249) on channel/CLI turns too — + // long-running channel threads otherwise grew unbounded until the cap error. + let context_window = provider.effective_context_window(model).await; + + tracing::info!( + model, + max_iterations, + observed = on_progress.is_some(), + context_window, + "[channel:graph] routing channel turn through tinyagents harness" + ); + let outcome = run_turn_via_tinyagents_shared( + provider, + model, + temperature, + prepared, + vec![extra_arc, tools_registry], + allowed, + max_iterations, + // Mirror the harness event stream onto AgentProgress when the caller + // (e.g. channel dispatch) supplied a progress sink. + on_progress, + // Top-level (parent) turn — no child-progress attribution. + None, + // Resolved above — drives the context-window summarization step. + context_window, + // No mid-flight steering on the channel path. + None, + // No early-exit pause on the channel path. + &[], + // Channels surface the cap as an error (legacy `ErrorCheckpoint`), so no + // graceful cap pause/summary here. + false, + // Bound the model's per-call output (legacy parity — channel turns ran at + // the standard per-turn budget). + Some(crate::openhuman::inference::provider::AGENT_TURN_MAX_OUTPUT_TOKENS), + // Context middlewares: cache-align + default tool-result byte cap (the + // channel path has no session `ContextManager` to source config from). + crate::openhuman::tinyagents::TurnContextMiddleware::defaults(), + ) + .await?; + // Append only this turn's typed suffix (assistant tool-calls + tool results + + // final assistant), serialized with the matching dispatcher so a native tool + // round persists as the `{content, tool_calls}` / `{tool_call_id, content}` + // envelope (re-parsed by `convert::chat_message_to_message` next turn) rather + // than an assistant with no `tool_calls` followed by an orphan `tool` row. + // Using `outcome.conversation` (the typed messages-since-last-user) avoids + // indexing into a post-trim `outcome.history` with the pre-trim `prior_len`, + // which could drop current-turn messages when compaction reshaped the run. + use crate::openhuman::agent::dispatcher::ToolDispatcher; + let suffix = if native_tools { + crate::openhuman::agent::dispatcher::NativeToolDispatcher + .to_provider_messages(&outcome.conversation) + } else { + // History serialization is format-independent for prompt-guided providers + // (tool calls already ride the visible assistant text); the XML dispatcher + // renders the flat `[Tool results]` shape. + crate::openhuman::agent::dispatcher::XmlToolDispatcher + .to_provider_messages(&outcome.conversation) + }; + history.extend(suffix); + Ok(outcome.text) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::openhuman::inference::provider::{ChatResponse, ToolCall}; + use crate::openhuman::tools::ToolResult; + use async_trait::async_trait; + use std::sync::atomic::{AtomicUsize, Ordering}; + + struct PingTool; + #[async_trait] + impl Tool for PingTool { + fn name(&self) -> &str { + "ping" + } + fn description(&self) -> &str { + "ping" + } + fn parameters_schema(&self) -> serde_json::Value { + serde_json::json!({"type": "object"}) + } + async fn execute(&self, _a: serde_json::Value) -> anyhow::Result { + Ok(ToolResult::success("pong")) + } + } + + struct PingThenDone { + calls: AtomicUsize, + } + #[async_trait] + impl Provider for PingThenDone { + async fn chat_with_system( + &self, + _s: Option<&str>, + _m: &str, + _model: &str, + _t: f64, + ) -> anyhow::Result { + Ok(String::new()) + } + async fn chat( + &self, + _r: crate::openhuman::inference::provider::ChatRequest<'_>, + _model: &str, + _t: f64, + ) -> anyhow::Result { + let n = self.calls.fetch_add(1, Ordering::SeqCst); + if n == 0 { + Ok(ChatResponse { + tool_calls: vec![ToolCall { + id: "p".to_string(), + name: "ping".to_string(), + arguments: "{}".to_string(), + extra_content: None, + }], + ..Default::default() + }) + } else { + Ok(ChatResponse { + text: Some("channel done".to_string()), + ..Default::default() + }) + } + } + fn supports_native_tools(&self) -> bool { + true + } + } + + #[tokio::test] + async fn channel_turn_runs_through_the_graph() { + let registry: Arc>> = Arc::new(vec![Box::new(PingTool)]); + let mut history = vec![ChatMessage::user("ping please")]; + let text = run_channel_turn_via_graph( + Arc::new(PingThenDone { + calls: AtomicUsize::new(0), + }), + &mut history, + registry, + vec![], + None, + "mock-model", + 0.0, + 10, + MultimodalConfig::default(), + MultimodalFileConfig::default(), + None, + ) + .await + .expect("channel graph turn runs"); + assert_eq!(text, "channel done"); + assert!(history.iter().any(|m| m.content.contains("pong"))); + } +} diff --git a/src/openhuman/agent/harness/harness_gap_tests.rs b/src/openhuman/agent/harness/harness_gap_tests.rs index aec85768d..abfb9d5a0 100644 --- a/src/openhuman/agent/harness/harness_gap_tests.rs +++ b/src/openhuman/agent/harness/harness_gap_tests.rs @@ -22,7 +22,6 @@ //! only the tag body (JSON) is used. use crate::openhuman::agent::error::AgentError; -use crate::openhuman::agent::harness::tool_loop::run_tool_call_loop; use crate::openhuman::context::guard::{ContextCheckResult, ContextGuard}; use crate::openhuman::inference::provider::traits::ProviderCapabilities; use crate::openhuman::inference::provider::Provider; @@ -117,275 +116,6 @@ fn multimodal_file_cfg() -> crate::openhuman::config::MultimodalFileConfig { // result injected → LLM produces final text. // ───────────────────────────────────────────────────────────────────────────── -#[tokio::test] -async fn full_turn_cycle_user_llm_tool_result_final() { - // Round 1: LLM requests the "echo" tool. - // Round 2: LLM produces a final reply after seeing the tool result. - let provider = ScriptedProvider { - responses: Mutex::new(vec![ - Ok(ChatResponse { - text: Some("{\"name\":\"echo\",\"arguments\":{}}".into()), - tool_calls: vec![], - usage: None, - reasoning_content: None, - }), - Ok(ChatResponse { - text: Some("The tool said: echo-out".into()), - tool_calls: vec![], - usage: None, - reasoning_content: None, - }), - ]), - }; - let mut history = vec![ChatMessage::user("please echo something")]; - let tools: Vec> = vec![Box::new(EchoTool)]; - - let result = run_tool_call_loop( - &provider, - &mut history, - &tools, - "test-provider", - "model", - 0.0, - true, - "channel", - &multimodal_cfg(), - &multimodal_file_cfg(), - 2, - None, - None, - &[], - None, - None, - &crate::openhuman::tools::policy::DefaultToolPolicy, - ) - .await - .expect("full turn cycle should succeed"); - - assert_eq!(result, "The tool said: echo-out"); - - // History should contain: user | assistant (tool call) | user (tool results) | assistant (final) - let roles: Vec<&str> = history.iter().map(|m| m.role.as_str()).collect(); - assert_eq!( - roles, - vec!["user", "assistant", "user", "assistant"], - "history should have exactly 4 messages after one tool round-trip" - ); - - // The tool results message must contain the echo output. - let tool_results = &history[2]; - assert_eq!(tool_results.role, "user"); - assert!( - tool_results.content.contains("echo-out"), - "tool result must be echoed into history, got: {}", - tool_results.content - ); -} - -// ───────────────────────────────────────────────────────────────────────────── -// Item 1 — MaxIterationsExceeded downcasts to typed AgentError. -// ───────────────────────────────────────────────────────────────────────────── - -#[tokio::test] -async fn max_iterations_exceeded_downcasts_to_typed_agent_error() { - // Provider keeps requesting the same tool forever — the loop - // exhausts max_iterations=1 after one tool round-trip. - let provider = ScriptedProvider { - responses: Mutex::new(vec![Ok(ChatResponse { - text: Some("{\"name\":\"echo\",\"arguments\":{}}".into()), - tool_calls: vec![], - usage: None, - reasoning_content: None, - })]), - }; - let mut history = vec![ChatMessage::user("loop me")]; - let tools: Vec> = vec![Box::new(EchoTool)]; - - let err = run_tool_call_loop( - &provider, - &mut history, - &tools, - "test-provider", - "model", - 0.0, - true, - "channel", - &multimodal_cfg(), - &multimodal_file_cfg(), - 1, - None, - None, - &[], - None, - None, - &crate::openhuman::tools::policy::DefaultToolPolicy, - ) - .await - .expect_err("loop must fail when iterations exhausted"); - - // The anyhow error must downcast to the typed variant so callers - // (channels dispatch, web_channel run_chat_task, Sentry filter) - // can distinguish this deterministic outcome from transient failures. - let agent_err = err - .downcast_ref::() - .expect("error should downcast to AgentError"); - assert!( - matches!(agent_err, AgentError::MaxIterationsExceeded { max: 1 }), - "expected MaxIterationsExceeded(1), got: {agent_err}" - ); - - // The string representation must contain the canonical prefix used - // by the Sentry-emit suppression checks in channels dispatch. - assert!( - crate::openhuman::agent::error::is_max_iterations_error(&err.to_string()), - "is_max_iterations_error must match the error text: {}", - err - ); -} - -// ───────────────────────────────────────────────────────────────────────────── -// Item 4 — visible_tool_names whitelist: tool outside the set → treated -// as unknown; tool inside the set → executes normally. -// ───────────────────────────────────────────────────────────────────────────── - -#[tokio::test] -async fn visible_tool_names_rejects_tool_outside_whitelist() { - // Registry contains both "echo" and "ping". - // The whitelist only allows "ping". - // LLM calls "echo" (outside the whitelist) → should be treated as unknown. - // LLM then produces a final text after seeing the unknown-tool error. - let provider = ScriptedProvider { - responses: Mutex::new(vec![ - Ok(ChatResponse { - text: Some( - // Model calls the filtered-out tool. - "{\"name\":\"echo\",\"arguments\":{}}".into(), - ), - tool_calls: vec![], - usage: None, - reasoning_content: None, - }), - Ok(ChatResponse { - text: Some("corrected response".into()), - tool_calls: vec![], - usage: None, - reasoning_content: None, - }), - ]), - }; - let mut history = vec![ChatMessage::user("echo something")]; - let tools: Vec> = vec![Box::new(EchoTool), Box::new(PingTool)]; - - // Whitelist: only "ping" is visible. - let whitelist: HashSet = ["ping".to_string()].into(); - - let result = run_tool_call_loop( - &provider, - &mut history, - &tools, - "test-provider", - "model", - 0.0, - true, - "channel", - &multimodal_cfg(), - &multimodal_file_cfg(), - 2, - None, - Some(&whitelist), - &[], - None, - None, - &crate::openhuman::tools::policy::DefaultToolPolicy, - ) - .await - .expect("loop should recover after whitelisted-out tool call"); - - assert_eq!(result, "corrected response"); - - // The tool results injected back to the LLM must report "echo" as unknown — - // it was filtered out by the whitelist. - let tool_results = history - .iter() - .find(|m| m.role == "user" && m.content.contains("[Tool results]")) - .expect("tool results must be appended after tool call"); - assert!( - tool_results.content.contains("Unknown tool: echo"), - "whitelisted-out tool must be reported as unknown, got: {}", - tool_results.content - ); -} - -#[tokio::test] -async fn visible_tool_names_allows_tool_inside_whitelist() { - // Whitelist includes "echo" — the call should execute normally. - let provider = ScriptedProvider { - responses: Mutex::new(vec![ - Ok(ChatResponse { - text: Some("{\"name\":\"echo\",\"arguments\":{}}".into()), - tool_calls: vec![], - usage: None, - reasoning_content: None, - }), - Ok(ChatResponse { - text: Some("heard echo-out".into()), - tool_calls: vec![], - usage: None, - reasoning_content: None, - }), - ]), - }; - let mut history = vec![ChatMessage::user("echo something")]; - let tools: Vec> = vec![Box::new(EchoTool)]; - let whitelist: HashSet = ["echo".to_string()].into(); - - let result = run_tool_call_loop( - &provider, - &mut history, - &tools, - "test-provider", - "model", - 0.0, - true, - "channel", - &multimodal_cfg(), - &multimodal_file_cfg(), - 2, - None, - Some(&whitelist), - &[], - None, - None, - &crate::openhuman::tools::policy::DefaultToolPolicy, - ) - .await - .expect("whitelisted tool should execute"); - - assert_eq!(result, "heard echo-out"); - - // Tool result must contain the actual tool output, not the unknown-tool message. - let tool_results = history - .iter() - .find(|m| m.role == "user" && m.content.contains("[Tool results]")) - .expect("tool results must be appended"); - assert!( - tool_results.content.contains("echo-out"), - "tool should have executed and returned its output, got: {}", - tool_results.content - ); - assert!( - !tool_results.content.contains("Unknown tool"), - "allowed tool must not be reported as unknown, got: {}", - tool_results.content - ); -} - -// ───────────────────────────────────────────────────────────────────────────── -// Item 5 — ContextGuard: ContextExhausted is surfaced cleanly. -// (Unit test on the guard directly; the loop integration path is -// exercised implicitly via context_guard.check() inside the loop.) -// ───────────────────────────────────────────────────────────────────────────── - #[test] fn context_guard_exhausted_after_circuit_breaker_and_95pct_utilization() { // Simulate the scenario where compaction has failed 3 times (circuit diff --git a/src/openhuman/agent/harness/interrupt.rs b/src/openhuman/agent/harness/interrupt.rs deleted file mode 100644 index ca8befbc3..000000000 --- a/src/openhuman/agent/harness/interrupt.rs +++ /dev/null @@ -1,166 +0,0 @@ -//! Graceful interrupt fence — handles SIGINT / Ctrl+C and `/stop` commands. -//! -//! The interrupt fence is checked at key points in the orchestrator loop: -//! - Before each DAG level execution -//! - Before each tool execution in the tool loop -//! - Inside sub-agent spawn points -//! -//! On interrupt, running sub-agents are cancelled, memory is flushed, -//! and the Archivist fires with partial context. - -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::Arc; - -/// Thread-safe interrupt flag that can be checked throughout the agent harness. -#[derive(Clone)] -pub struct InterruptFence { - flag: Arc, -} - -impl InterruptFence { - /// Create a new interrupt fence (not triggered). - pub fn new() -> Self { - Self { - flag: Arc::new(AtomicBool::new(false)), - } - } - - /// Check whether an interrupt has been requested. - pub fn is_interrupted(&self) -> bool { - self.flag.load(Ordering::Relaxed) - } - - /// Trigger the interrupt (called from signal handler or `/stop` command). - pub fn trigger(&self) { - self.flag.store(true, Ordering::Relaxed); - tracing::info!("[interrupt] interrupt fence triggered"); - } - - /// Reset the fence (e.g. at the start of a new session). - pub fn reset(&self) { - self.flag.store(false, Ordering::Relaxed); - } - - /// Get a raw `Arc` handle for passing to signal handlers. - pub fn flag_handle(&self) -> Arc { - self.flag.clone() - } - - /// Install a `tokio::signal::ctrl_c()` handler that triggers this fence. - /// - /// This spawns a background task that waits for Ctrl+C and sets the flag. - /// The task runs until the process exits. - pub fn install_signal_handler(&self) { - let flag = self.flag.clone(); - tokio::spawn(async move { - loop { - match tokio::signal::ctrl_c().await { - Ok(()) => { - if flag.load(Ordering::Relaxed) { - // Second Ctrl+C — hard exit. - tracing::warn!("[interrupt] second Ctrl+C received, forcing exit"); - std::process::exit(130); - } - flag.store(true, Ordering::Relaxed); - tracing::info!( - "[interrupt] Ctrl+C received — gracefully stopping. Press again to force exit." - ); - } - Err(e) => { - tracing::error!("[interrupt] failed to listen for Ctrl+C: {e}"); - break; - } - } - } - }); - } -} - -impl Default for InterruptFence { - fn default() -> Self { - Self::new() - } -} - -/// Error returned when an operation is cancelled due to an interrupt. -#[derive(Debug, thiserror::Error)] -#[error("operation interrupted by user")] -pub struct InterruptedError; - -/// Helper: check the fence and return `Err(InterruptedError)` if triggered. -pub fn check_interrupt(fence: &InterruptFence) -> Result<(), InterruptedError> { - if fence.is_interrupted() { - Err(InterruptedError) - } else { - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn new_fence_is_not_interrupted() { - let fence = InterruptFence::new(); - assert!(!fence.is_interrupted()); - } - - #[test] - fn trigger_sets_interrupted() { - let fence = InterruptFence::new(); - fence.trigger(); - assert!(fence.is_interrupted()); - } - - #[test] - fn reset_clears_interrupted() { - let fence = InterruptFence::new(); - fence.trigger(); - assert!(fence.is_interrupted()); - fence.reset(); - assert!(!fence.is_interrupted()); - } - - #[test] - fn flag_handle_shares_state() { - let fence = InterruptFence::new(); - let handle = fence.flag_handle(); - handle.store(true, std::sync::atomic::Ordering::Relaxed); - assert!(fence.is_interrupted()); - } - - #[test] - fn clone_shares_state() { - let fence = InterruptFence::new(); - let clone = fence.clone(); - fence.trigger(); - assert!(clone.is_interrupted()); - } - - #[test] - fn default_is_not_interrupted() { - let fence = InterruptFence::default(); - assert!(!fence.is_interrupted()); - } - - #[test] - fn check_interrupt_ok_when_not_triggered() { - let fence = InterruptFence::new(); - assert!(check_interrupt(&fence).is_ok()); - } - - #[test] - fn check_interrupt_err_when_triggered() { - let fence = InterruptFence::new(); - fence.trigger(); - let err = check_interrupt(&fence).unwrap_err(); - assert_eq!(err.to_string(), "operation interrupted by user"); - } - - #[test] - fn interrupted_error_display() { - let err = InterruptedError; - assert_eq!(format!("{err}"), "operation interrupted by user"); - } -} diff --git a/src/openhuman/agent/harness/mod.rs b/src/openhuman/agent/harness/mod.rs index f772ea4a5..db9ac0b6f 100644 --- a/src/openhuman/agent/harness/mod.rs +++ b/src/openhuman/agent/harness/mod.rs @@ -19,6 +19,7 @@ //! - **[`fork_context`]**: Task-local storage for parent context sharing. //! - **[`interrupt`]**: Infrastructure for graceful cancellation of agent loops. +pub mod agent_graph; pub mod archivist; pub(crate) mod builtin_definitions; pub(crate) mod compaction; @@ -27,56 +28,47 @@ pub mod definition; pub(crate) mod definition_loader; pub(crate) mod engine; pub mod fork_context; +pub(crate) mod graph; mod instructions; -pub mod interrupt; pub(crate) mod memory_context; pub(crate) mod memory_context_safety; -pub mod model_vision_context; -mod parse; +pub(crate) mod parse; pub(crate) mod payload_summarizer; pub mod run_queue; pub mod sandbox_context; -pub(crate) mod self_healing; pub mod session; -pub(crate) mod session_queue; pub(crate) mod spawn_depth_context; pub mod subagent_runner; pub mod task_recency_context; -pub(crate) mod token_budget; pub(crate) mod tool_filter; -mod tool_loop; pub(crate) mod tool_result_artifacts; pub mod turn_attachments_context; pub mod turn_subagent_usage; pub mod worktree_context; +pub use agent_graph::{AgentGraph, AgentTurnRequest, AgentTurnResult, AgentTurnUsage}; pub use definition::{ AgentDefinition, AgentDefinitionRegistry, DefinitionSource, ModelSpec, PromptSource, SandboxMode, ToolScope, TriggerMemoryAgent, }; pub use fork_context::{ - current_agent_context_prepared_sources, current_parent, with_agent_context_prepared_sources, - with_parent_context, AgentContextPreparedSource, ParentExecutionContext, + current_agent_context_prepared_sources, current_parent, push_agent_context_prepared_source, + with_agent_context_prepared_sources, with_parent_context, AgentContextPreparedSource, + ParentExecutionContext, }; -pub use interrupt::{check_interrupt, InterruptFence, InterruptedError}; -pub use model_vision_context::{current_model_vision, with_current_model_vision}; pub use sandbox_context::{current_sandbox_mode, with_current_sandbox_mode}; pub(crate) use spawn_depth_context::{current_spawn_depth, with_spawn_depth, MAX_SPAWN_DEPTH}; pub use subagent_runner::{run_subagent, SubagentRunError, SubagentRunOptions}; pub use task_recency_context::{current_task_recency_window, with_task_recency_window}; pub use worktree_context::{current_action_dir_override, with_action_dir_override}; +pub(crate) use graph::run_channel_turn_via_graph; pub(crate) use instructions::build_tool_instructions_filtered; pub(crate) use parse::parse_tool_calls; -pub(crate) use tool_loop::run_tool_call_loop; #[cfg(test)] -mod bughunt_tests; #[cfg(test)] -pub(crate) mod test_support; #[cfg(test)] -mod test_support_tests; - #[cfg(test)] mod harness_gap_tests; #[cfg(test)] diff --git a/src/openhuman/agent/harness/model_vision_context.rs b/src/openhuman/agent/harness/model_vision_context.rs deleted file mode 100644 index 402ecd426..000000000 --- a/src/openhuman/agent/harness/model_vision_context.rs +++ /dev/null @@ -1,75 +0,0 @@ -//! Task-local carrier for the **current session/sub-agent's user-configured -//! vision capability** so the deep turn engine's image gate can honor a custom -//! (BYOK) model's `model_registry.vision` flag without widening -//! [`crate::openhuman::agent::harness::engine::run_turn_engine`]'s signature. -//! -//! Managed-backend models advertise vision via `Provider::supports_vision()`, so -//! the gate accepts their image turns already. Custom OpenAI-compatible providers -//! report `supports_vision() == false` (a provider endpoint can't know a per-model -//! property), so without this the gate would reject image turns for a model the -//! user explicitly marked vision-capable. This task-local surfaces that per-model -//! flag — computed once at session build (where the full `Config` / `model_registry` -//! and the resolved model id coexist) — to the gate. -//! -//! Mirrors [`super::sandbox_context`]. When unset (CLI / direct invocation / tests -//! that never wrapped the call) [`current_model_vision`] returns `None` and the -//! gate falls back to the provider capability only — strictly additive. - -tokio::task_local! { - /// User-configured vision capability for the currently-executing - /// session/sub-agent's model. Scoped per turn by the turn loop + subagent - /// runner. `None` when unset. - pub static CURRENT_MODEL_VISION: bool; -} - -/// Returns the current model's user-configured vision flag, if scope is active. -pub fn current_model_vision() -> Option { - CURRENT_MODEL_VISION.try_with(|v| *v).ok() -} - -/// Run `future` with `vision` installed as the current model's vision flag. -/// Intended call site is around each `run_turn_engine` invocation. -pub async fn with_current_model_vision(vision: bool, future: F) -> R -where - F: std::future::Future, -{ - CURRENT_MODEL_VISION.scope(vision, future).await -} - -#[cfg(test)] -mod tests { - use super::*; - - #[tokio::test] - async fn current_model_vision_returns_none_outside_scope() { - assert_eq!(current_model_vision(), None); - } - - #[tokio::test] - async fn with_current_model_vision_installs_value() { - let observed = with_current_model_vision(true, async { current_model_vision() }).await; - assert_eq!(observed, Some(true)); - } - - #[tokio::test] - async fn with_current_model_vision_does_not_leak_across_scopes() { - with_current_model_vision(true, async { - assert_eq!(current_model_vision(), Some(true)); - }) - .await; - assert_eq!(current_model_vision(), None); - } - - #[tokio::test] - async fn nested_scope_overrides_outer() { - with_current_model_vision(false, async { - assert_eq!(current_model_vision(), Some(false)); - with_current_model_vision(true, async { - assert_eq!(current_model_vision(), Some(true)); - }) - .await; - assert_eq!(current_model_vision(), Some(false)); - }) - .await; - } -} diff --git a/src/openhuman/agent/harness/payload_summarizer.rs b/src/openhuman/agent/harness/payload_summarizer.rs index 350c03781..c1520211e 100644 --- a/src/openhuman/agent/harness/payload_summarizer.rs +++ b/src/openhuman/agent/harness/payload_summarizer.rs @@ -80,10 +80,9 @@ pub struct SummarizedPayload { /// agent history. Implementations decide the threshold, the dispatch /// mechanism, and the failure policy. /// -/// Wired into the tool-execution sites in -/// [`super::tool_loop::run_tool_call_loop`] and +/// Wired into the tool-execution site in /// [`crate::openhuman::agent::harness::session::Agent::execute_tool_call`] -/// via an `Option<&dyn PayloadSummarizer>` parameter so legacy callers +/// via an `Option<&dyn PayloadSummarizer>` parameter so callers /// (CLI, REPL, tests, non-orchestrator sub-agents) can pass `None` and /// keep the existing pass-through behaviour. #[async_trait] @@ -371,6 +370,7 @@ mod tests { delegate_name: None, agent_tier: crate::openhuman::agent::harness::definition::AgentTier::Worker, source: DefinitionSource::Builtin, + graph: Default::default(), } } diff --git a/src/openhuman/agent/harness/self_healing.rs b/src/openhuman/agent/harness/self_healing.rs deleted file mode 100644 index f5495c20b..000000000 --- a/src/openhuman/agent/harness/self_healing.rs +++ /dev/null @@ -1,290 +0,0 @@ -//! Self-healing interceptor — auto-polyfill when commands are missing. -//! -//! When the Code Executor's shell tool returns "command not found" or similar, -//! the interceptor spawns a ToolMaker sub-agent to write a polyfill script, -//! then retries the original command. - -use crate::openhuman::tools::ToolResult; -use std::path::{Path, PathBuf}; - -/// Maximum number of self-heal attempts per unique command. -const MAX_HEAL_ATTEMPTS: u8 = 2; - -/// Patterns in tool error output that indicate a missing command/binary. -const MISSING_CMD_PATTERNS: &[&str] = &[ - "command not found", - ": not found", - "not installed", - "No such file or directory", - "not recognized as an internal or external command", - "is not recognized", - "unable to find", -]; - -/// Interceptor that detects missing-command errors and spawns ToolMaker agents. -pub struct SelfHealingInterceptor { - /// Directory where polyfill scripts are written. - polyfill_dir: PathBuf, - /// Whether self-healing is enabled. - enabled: bool, - /// Track heal attempts per command to enforce MAX_HEAL_ATTEMPTS. - attempts: std::collections::HashMap, -} - -impl SelfHealingInterceptor { - pub fn new(workspace_dir: &Path, enabled: bool) -> Self { - let polyfill_dir = workspace_dir.join("polyfills"); - Self { - polyfill_dir, - enabled, - attempts: std::collections::HashMap::new(), - } - } - - /// Check if a tool result indicates a missing command that can be self-healed. - /// - /// Returns `Some(command_name)` if the error matches a known missing-command pattern - /// and we haven't exceeded the retry limit. - pub fn detect_missing_command(&mut self, result: &ToolResult) -> Option { - if !self.enabled || !result.is_error { - return None; - } - - let output_text = result.output().to_lowercase(); - let combined = output_text; - - // Check if the error matches any missing-command pattern. - let is_missing = MISSING_CMD_PATTERNS - .iter() - .any(|pattern| combined.contains(&pattern.to_lowercase())); - - if !is_missing { - return None; - } - - // Try to extract the command name from the error. - let cmd = extract_command_name(&combined)?; - - // Check retry limit. - let count = self.attempts.entry(cmd.clone()).or_insert(0); - if *count >= MAX_HEAL_ATTEMPTS { - tracing::debug!( - "[self-healing] max attempts ({MAX_HEAL_ATTEMPTS}) reached for command: {cmd}" - ); - return None; - } - *count += 1; - - tracing::info!( - "[self-healing] detected missing command: {cmd} (attempt {}/{})", - *count, - MAX_HEAL_ATTEMPTS - ); - - Some(cmd) - } - - /// Build the prompt for the ToolMaker sub-agent. - pub fn tool_maker_prompt(&self, missing_command: &str, original_context: &str) -> String { - format!( - "The command `{missing_command}` is not available in this environment.\n\ - \n\ - Write a polyfill script that accomplishes the equivalent functionality.\n\ - Save it to: {polyfill_dir}/{missing_command}\n\ - Make it executable with `chmod +x`.\n\ - \n\ - Original context:\n{original_context}\n\ - \n\ - Requirements:\n\ - - Use only standard tools likely available (bash, python3, awk, sed, curl).\n\ - - The script should accept the same arguments as the original command.\n\ - - Keep it minimal — just enough to accomplish the immediate task.\n\ - - Do NOT install packages or use sudo.", - polyfill_dir = self.polyfill_dir.display() - ) - } - - /// Get the polyfill directory path. - pub fn polyfill_dir(&self) -> &Path { - &self.polyfill_dir - } - - /// Ensure the polyfill directory exists. - pub async fn ensure_polyfill_dir(&self) -> anyhow::Result<()> { - if !self.polyfill_dir.exists() { - tokio::fs::create_dir_all(&self.polyfill_dir).await?; - tracing::debug!( - "[self-healing] created polyfill directory: {}", - self.polyfill_dir.display() - ); - } - Ok(()) - } - - /// Reset attempt counters (e.g. between sessions). - pub fn reset(&mut self) { - self.attempts.clear(); - } -} - -/// Try to extract a command name from an error message. -/// -/// Handles patterns like: -/// - "bash: foo: command not found" -/// - "sh: 1: foo: not found" -/// - "'foo' is not recognized" -fn extract_command_name(error: &str) -> Option { - // Pattern: "bash: CMD: command not found" - if let Some(idx) = error.find(": command not found") { - let before = &error[..idx]; - if let Some(colon_idx) = before.rfind(": ") { - let cmd = before[colon_idx + 2..].trim(); - if !cmd.is_empty() && cmd.len() < 64 { - return Some(cmd.to_string()); - } - } - // Try without preceding colon. - let cmd = before.trim(); - if let Some(last_word) = cmd.split_whitespace().last() { - if last_word.len() < 64 { - return Some(last_word.to_string()); - } - } - } - - // Pattern: "sh: N: CMD: not found" - if error.contains(": not found") { - let parts: Vec<&str> = error.split(':').collect(); - if parts.len() >= 3 { - let candidate = parts[parts.len() - 2].trim(); - if !candidate.is_empty() - && candidate.len() < 64 - && !candidate.chars().all(|c| c.is_ascii_digit()) - { - return Some(candidate.to_string()); - } - } - } - - // Pattern: "'CMD' is not recognized" - if error.contains("is not recognized") { - let stripped = error.replace(['\'', '"'], ""); - if let Some(cmd) = stripped.split_whitespace().next() { - if cmd.len() < 64 { - return Some(cmd.to_string()); - } - } - } - - None -} - -#[cfg(test)] -mod tests { - use super::*; - - fn make_error_result(error: &str) -> ToolResult { - ToolResult::error(error) - } - - #[test] - fn detects_bash_command_not_found() { - let mut interceptor = SelfHealingInterceptor::new(Path::new("/tmp"), true); - let result = make_error_result("bash: jq: command not found"); - let cmd = interceptor.detect_missing_command(&result); - assert_eq!(cmd, Some("jq".to_string())); - } - - #[test] - fn detects_sh_not_found() { - let mut interceptor = SelfHealingInterceptor::new(Path::new("/tmp"), true); - let result = make_error_result("sh: 1: nmap: not found"); - let cmd = interceptor.detect_missing_command(&result); - assert_eq!(cmd, Some("nmap".to_string())); - } - - #[test] - fn respects_max_attempts() { - let mut interceptor = SelfHealingInterceptor::new(Path::new("/tmp"), true); - let result = make_error_result("bash: jq: command not found"); - - // First two attempts should succeed. - assert!(interceptor.detect_missing_command(&result).is_some()); - assert!(interceptor.detect_missing_command(&result).is_some()); - // Third should be None (max attempts reached). - assert!(interceptor.detect_missing_command(&result).is_none()); - } - - #[test] - fn ignores_successful_results() { - let mut interceptor = SelfHealingInterceptor::new(Path::new("/tmp"), true); - let result = ToolResult::success("command not found"); // misleading output - assert!(interceptor.detect_missing_command(&result).is_none()); - } - - #[test] - fn disabled_returns_none() { - let mut interceptor = SelfHealingInterceptor::new(Path::new("/tmp"), false); - let result = make_error_result("bash: jq: command not found"); - assert!(interceptor.detect_missing_command(&result).is_none()); - } - - #[test] - fn reset_clears_attempts() { - let mut interceptor = SelfHealingInterceptor::new(Path::new("/tmp"), true); - let result = make_error_result("bash: jq: command not found"); - interceptor.detect_missing_command(&result); - interceptor.detect_missing_command(&result); - interceptor.reset(); - // After reset, should detect again. - assert!(interceptor.detect_missing_command(&result).is_some()); - } - - #[test] - fn tool_maker_prompt_includes_command() { - let interceptor = SelfHealingInterceptor::new(Path::new("/workspace"), true); - let prompt = interceptor.tool_maker_prompt("jq", "parse json output"); - let normalized = prompt.replace('\\', "/"); - assert!(normalized.contains("jq")); - assert!(normalized.contains("/workspace/polyfills/jq")); - assert!(normalized.contains("parse json output")); - } - - #[test] - fn detects_windows_not_recognized_pattern() { - let mut interceptor = SelfHealingInterceptor::new(Path::new("/tmp"), true); - let result = make_error_result("'rg' is not recognized as an internal or external command"); - let cmd = interceptor.detect_missing_command(&result); - assert_eq!(cmd, Some("rg".to_string())); - } - - #[test] - fn ignores_non_matching_or_malformed_missing_command_patterns() { - let mut interceptor = SelfHealingInterceptor::new(Path::new("/tmp"), true); - assert!(interceptor - .detect_missing_command(&make_error_result("permission denied")) - .is_none()); - - let too_long = format!("bash: {}: command not found", "x".repeat(80)); - assert!(interceptor - .detect_missing_command(&make_error_result(&too_long)) - .is_none()); - - assert_eq!(extract_command_name("sh: 1: 1234: not found"), None); - } - - #[tokio::test] - async fn ensure_polyfill_dir_creates_directory_and_exposes_path() { - let workspace = tempfile::TempDir::new().expect("temp workspace"); - let interceptor = SelfHealingInterceptor::new(workspace.path(), true); - assert!(!interceptor.polyfill_dir().exists()); - - interceptor - .ensure_polyfill_dir() - .await - .expect("polyfill dir should be created"); - - assert!(interceptor.polyfill_dir().exists()); - assert!(interceptor.polyfill_dir().ends_with("polyfills")); - } -} diff --git a/src/openhuman/agent/harness/session/agent_tool_exec.rs b/src/openhuman/agent/harness/session/agent_tool_exec.rs index 305c8dcb9..f7f165423 100644 --- a/src/openhuman/agent/harness/session/agent_tool_exec.rs +++ b/src/openhuman/agent/harness/session/agent_tool_exec.rs @@ -182,9 +182,7 @@ pub(super) async fn run_agent_tool_call( }; let policy = tool.timeout_policy(&call.arguments); let (tool_deadline, timeout_secs) = - crate::openhuman::agent::harness::engine::tools::resolve_tool_deadline( - policy, - ); + crate::openhuman::tool_timeout::resolve_tool_deadline(policy); match policy { crate::openhuman::tools::traits::ToolTimeout::Secs(req) => { tracing::debug!( diff --git a/src/openhuman/agent/harness/session/builder/factory.rs b/src/openhuman/agent/harness/session/builder/factory.rs index 19cb04f89..7fe96da09 100644 --- a/src/openhuman/agent/harness/session/builder/factory.rs +++ b/src/openhuman/agent/harness/session/builder/factory.rs @@ -446,18 +446,11 @@ impl Agent { // baseline behaviour is identical to the legacy // `create_intelligent_routing_provider` path. // - // What we deliberately lose for now: the ReliableProvider retry - // wrapper, model_routes translation, and intelligent local/cloud - // task hinting that the legacy router added on top of the raw - // backend. Those are valuable but orthogonal — they can be layered - // back on top of the factory's output in a follow-up without - // re-introducing the routing bypass. - let _ = provider::ProviderRuntimeOptions { - auth_profile_override: None, - openhuman_dir: config.config_path.parent().map(std::path::PathBuf::from), - secrets_encrypt: config.secrets.encrypt, - reasoning_enabled: config.runtime.reasoning_enabled, - }; + // The ReliableProvider retry/backoff + model-fallback wrapper is + // re-layered on top of the factory's resolved backend below (issue + // #4249, 1c). `model_routes` translation and intelligent local/cloud + // task hinting now live in the unified routing layer (router.rs) rather + // than a per-session wrapper, so they are not re-wrapped here. // Explicit `hint:` and known-tier model strings route to the // matching workload (so a subagent declaring `hint:reasoning` still // gets the user's `reasoning_provider`). Everything else — including @@ -476,8 +469,23 @@ impl Agent { // `chat_provider` selection. Subagents still set their own role // through `ModelSpec::Hint(...)` in the subagent runner. let provider_role = provider_role_for(agent_id, config.default_model.as_deref()); - let (provider, mut model_name): (Box, String) = + let (raw_provider, mut model_name): (Box, String) = crate::openhuman::inference::provider::create_chat_provider(provider_role, config)?; + // Re-layer the ReliableProvider retry/backoff + model-fallback wrapper on + // top of the factory's resolved backend (issue #4249, 1c). The migration to + // `create_chat_provider` dropped this; restore it so rate-limit/5xx retries + // and the user's `model_fallbacks` apply to the main chat turn exactly as + // the legacy `create_intelligent_routing_provider` path did. Capability + // probes (`supports_native_tools` / `supports_vision`) forward to the inner + // backend, so downstream dispatcher/vision selection is unchanged. + let provider: Box = Box::new( + crate::openhuman::inference::provider::reliable::ReliableProvider::new( + vec![(provider_role.to_string(), raw_provider)], + config.reliability.provider_retries, + config.reliability.provider_backoff_ms, + ) + .with_model_fallbacks(config.reliability.model_fallbacks.clone()), + ); log::info!( "[session-builder] agent_id={} provider_role={} resolved_model={} supports_native_tools={}", agent_id, @@ -1210,7 +1218,6 @@ impl Agent { builder = builder.payload_summarizer(ps); } builder = builder.archivist_hook(archivist_hook_arc); - builder = builder.unified_compaction_enabled(config.learning.unified_compaction_enabled); let mut agent = builder.build()?; let connected_integrations_initialized = prewarmed_integrations.is_some(); agent.connected_integrations = prewarmed_integrations.unwrap_or_default(); diff --git a/src/openhuman/agent/harness/session/builder/setters.rs b/src/openhuman/agent/harness/session/builder/setters.rs index 09e81ede8..1f1b5d844 100644 --- a/src/openhuman/agent/harness/session/builder/setters.rs +++ b/src/openhuman/agent/harness/session/builder/setters.rs @@ -9,7 +9,7 @@ use crate::openhuman::agent::harness::TriggerMemoryAgent; use crate::openhuman::agent::memory_loader::DefaultMemoryLoader; use crate::openhuman::agent_tool_policy::ToolPolicyEngine; use crate::openhuman::config::ContextConfig; -use crate::openhuman::context::{ContextManager, ProviderSummarizer, SegmentRecapSummarizer}; +use crate::openhuman::context::ContextManager; use crate::openhuman::memory::Memory; use crate::openhuman::tools::{Tool, ToolSpec}; use anyhow::Result; @@ -49,7 +49,6 @@ impl AgentBuilder { tokenjuice_compression: crate::openhuman::tokenjuice::AgentTokenjuiceCompression::Full, tool_policy: None, archivist_hook: None, - unified_compaction_enabled: true, } } @@ -351,21 +350,6 @@ impl AgentBuilder { self } - /// Phase 1.5 — gate the unified compaction path. - /// - /// When `true` (the default) and an archivist hook is wired in via - /// [`Self::archivist_hook`], the session's `ContextManager` summarizer is - /// wrapped with a [`SegmentRecapSummarizer`] that routes autocompaction - /// through the archivist's rolling recap (one LLM summarizer, soft-fallback - /// to [`ProviderSummarizer`] when the recap is unavailable). - /// - /// When `false` the `ProviderSummarizer` is used directly and Phase 1.5 is - /// completely absent from the hot path — behaviour is identical to today's. - pub fn unified_compaction_enabled(mut self, enabled: bool) -> Self { - self.unified_compaction_enabled = enabled; - self - } - /// Set the per-agent TokenJuice tool-output compression profile. pub fn tokenjuice_compression( mut self, @@ -457,57 +441,12 @@ impl AgentBuilder { // model's context window" routes through this single handle. let context_config = self.context_config.unwrap_or_default(); - // Phase 1.5 — unified compaction. - // - // When `unified_compaction_enabled` is true AND an archivist hook - // is wired in, wrap the inner `ProviderSummarizer` with a - // `SegmentRecapSummarizer`. The outer type: - // 1. Tries the rolling segment recap from the open segment. - // 2. Falls back to the inner `ProviderSummarizer` if unavailable. - // - // With the flag off OR no archivist, the plain `ProviderSummarizer` - // is used and Phase 1.5 is completely absent from the hot path - // — behaviour is identical to Phase 1. - let inner_summarizer: Arc = - Arc::new(ProviderSummarizer::new(provider.clone())); - let session_id_for_recap = self - .event_session_id - .clone() - .unwrap_or_else(|| "standalone".to_string()); - let summarizer: Arc = - if self.unified_compaction_enabled { - if let Some(ref archivist) = self.archivist_hook { - log::debug!( - "[agent::builder] unified_compaction_enabled=true — \ - wrapping summarizer with SegmentRecapSummarizer \ - session_id={session_id_for_recap}" - ); - Arc::new(SegmentRecapSummarizer::new( - Arc::clone(archivist), - session_id_for_recap, - inner_summarizer, - )) - } else { - log::debug!( - "[agent::builder] unified_compaction_enabled=true but \ - no archivist hook — using ProviderSummarizer" - ); - inner_summarizer - } - } else { - log::debug!( - "[agent::builder] unified_compaction_enabled=false — \ - using ProviderSummarizer (Phase 1.5 disabled)" - ); - inner_summarizer - }; - - let context = ContextManager::new( - &context_config, - summarizer, - model_name.clone(), - prompt_builder, - ); + // Live history reduction moved to the tinyagents graph + // (`ContextCompressionMiddleware` + `MessageTrimMiddleware`, issue + // #4249), so the session no longer constructs an in-turn summarizer + // here. The archivist hook still drives durable segment recaps on its + // own post-turn path; it is no longer coupled to context compaction. + let context = ContextManager::new(&context_config, prompt_builder); let workspace_dir = self .workspace_dir diff --git a/src/openhuman/agent/harness/session/mod.rs b/src/openhuman/agent/harness/session/mod.rs index 4a0b3697e..02e75b1e1 100644 --- a/src/openhuman/agent/harness/session/mod.rs +++ b/src/openhuman/agent/harness/session/mod.rs @@ -27,7 +27,6 @@ mod runtime; pub(crate) mod transcript; mod turn; mod turn_checkpoint; -mod turn_engine_adapter; mod types; pub use migration::{migrate_session_layout_if_needed, MigrationOutcome}; diff --git a/src/openhuman/agent/harness/session/turn/core.rs b/src/openhuman/agent/harness/session/turn/core.rs index 703130d87..448f89c47 100644 --- a/src/openhuman/agent/harness/session/turn/core.rs +++ b/src/openhuman/agent/harness/session/turn/core.rs @@ -1,6 +1,5 @@ //! Core turn execution: the main `turn()` method and `inject_agent_experience_context()`. -use super::super::turn_engine_adapter::{AgentCheckpoint, AgentObserver, AgentToolSource}; use super::super::types::Agent; use super::{ integration_announcement_note, mcp_announcement_note, newly_connected_slugs, @@ -15,9 +14,7 @@ use crate::openhuman::agent::progress::AgentProgress; use crate::openhuman::agent_experience::{ prepend_experience_block, render_experience_hits, AgentExperienceStore, ExperienceQuery, }; -use crate::openhuman::inference::provider::{ - ChatMessage, ConversationMessage, AGENT_TURN_MAX_OUTPUT_TOKENS, -}; +use crate::openhuman::inference::provider::{ChatMessage, ConversationMessage}; use crate::openhuman::memory::MemoryCategory; use crate::openhuman::util::truncate_with_ellipsis; @@ -59,20 +56,33 @@ fn should_run_super_context( is_orchestrator && first_turn && !has_prior_conversation && enabled } -fn parse_context_bundle_has_enough_context(bundle: &str) -> Option { - const PREFIX: &str = "has_enough_context:"; - let line = bundle.lines().map(str::trim).find(|line| { - line.get(..PREFIX.len()) - .is_some_and(|prefix| prefix.eq_ignore_ascii_case(PREFIX)) - })?; - let value = line[PREFIX.len()..].trim(); - if value.eq_ignore_ascii_case("true") { - Some(true) - } else if value.eq_ignore_ascii_case("false") { - Some(false) - } else { - None +// `parse_context_bundle_has_enough_context` moved to +// `tinyagents::middleware` alongside the `SuperContextMiddleware` graph node +// that now owns the first-turn context-collection pass (#4249). + +/// Flatten the assistant tool calls a turn produced into [`ToolCallRecord`]s for +/// the deterministic cap checkpoint (it lists the tools that ran). Tool success +/// isn't tracked per call here, so each is recorded optimistically; the listing +/// is a human-readable fallback, not authoritative accounting. +fn tool_records_from_conversation( + conversation: &[ConversationMessage], +) -> Vec { + let mut records = Vec::new(); + for msg in conversation { + if let ConversationMessage::AssistantToolCalls { tool_calls, .. } = msg { + for call in tool_calls { + records.push(hooks::ToolCallRecord { + name: call.name.clone(), + arguments: serde_json::from_str(&call.arguments) + .unwrap_or(serde_json::Value::Null), + success: true, + output_summary: String::new(), + duration_ms: 0, + }); + } + } } + records } fn render_agent_context_status_note(sources: &[harness::AgentContextPreparedSource]) -> String { @@ -588,70 +598,24 @@ impl Agent { .cached_transcript_messages .as_ref() .is_some_and(|msgs| msgs.iter().any(|m| m.role == "assistant")); - let enriched = if should_run_super_context( + // The scout no longer runs here imperatively: super context is now a + // before_model **graph node** (`SuperContextMiddleware`, installed via + // `context_mw.super_context` below). It runs the read-only `context_scout` + // on the first model call, folds the `[context_bundle]` into the user + // message, and registers its prepared-context source live so a later + // `agent_prepare_context` call self-suppresses. We only decide *whether* + // to enable the node here (the gate is unchanged). + let run_super_context = should_run_super_context( self.agent_definition_id == "orchestrator", first_turn, has_prior_conversation, self.context.super_context_enabled(), - ) { + ); + if run_super_context { log::info!( - "[agent_loop] super_context enabled — running harness-driven context collection (new thread, first turn)" + "[agent_loop] super_context enabled — installing the SuperContextMiddleware graph node (new thread, first turn)" ); - let scout = harness::with_parent_context(parent_context.clone(), { - let user_message = user_message.to_string(); - async move { - crate::openhuman::agent_orchestration::tools::run_context_scout( - &user_message, - None, - ) - .await - } - }) - .await; - match scout { - Ok(result) if !result.is_error => { - let bundle = result.output(); - agent_context_prepared_sources.push(harness::AgentContextPreparedSource { - source: "super context preparation".to_string(), - has_enough_context: parse_context_bundle_has_enough_context(&bundle), - }); - log::info!( - "[agent_loop] super_context bundle collected bundle_chars={}", - bundle.chars().count() - ); - format!( - "## Prepared context (super context)\n\nThe following context was \ - collected up-front by a read-only context scout before this turn. \ - Use it to ground your response; do not call `agent_prepare_context` \ - again for general preparation.\n\n\ - {bundle}\n\n---\n\n{enriched}" - ) - } - Ok(result) => { - // No usable bundle: leave `agent_context_prepared_sources` - // untouched. Recording a marker here would (a) make - // `render_agent_context_status_note` tell the model to "use - // the prepared context below" when none was injected, and - // (b) suppress `agent_prepare_context` for the rest of the - // turn — blocking a legitimate retry by any path that still - // exposes the tool. The dedup only needs to hold once a - // bundle was actually injected (the success arm above). - log::warn!( - "[agent_loop] super_context scout returned an error — proceeding without bundle: {}", - result.output() - ); - enriched - } - Err(err) => { - log::warn!( - "[agent_loop] super_context collection failed — proceeding without bundle: {err}" - ); - enriched - } - } - } else { - enriched - }; + } let enriched = if agent_context_prepared_sources.is_empty() { enriched @@ -720,266 +684,19 @@ impl Agent { self.session_key.clone(), ), ); - let mut tool_source = AgentToolSource { - tools: self.tools.clone(), - visible_tool_names: self.visible_tool_names.clone(), - tool_policy_session: self.tool_policy_session.clone(), - tool_policy: self.tool_policy.clone(), - payload_summarizer: self.payload_summarizer.clone(), - event_session_id: self.event_session_id().to_string(), - event_channel: self.event_channel().to_string(), - agent_definition_id: self.agent_definition_id.clone(), - prefer_markdown: self.context.prefer_markdown_tool_output(), - budget_bytes: self.context.tool_result_budget_bytes(), - compaction_enabled: self.context.compaction_enabled(), - tokenjuice_compression: self.tokenjuice_compression, - artifact_store: artifact_store.clone(), - should_send_specs: self.tool_dispatcher.should_send_tool_specs(), - advertised_specs: self.visible_tool_specs.as_ref().clone(), - records: Vec::new(), - }; - let progress = super::super::super::engine::TurnProgress::new(self.on_progress.clone()); - let parser = super::super::super::engine::DispatcherParser { - dispatcher: dispatcher.as_ref(), - }; - let checkpoint = AgentCheckpoint { - provider: self.provider.clone(), - dispatcher: self.tool_dispatcher.clone(), - model: effective_model.clone(), + // The whole turn runs through the tinyagents harness (issue #4249); + // the legacy `run_turn_engine` has been removed. Heap-allocate the + // (large) session-turn future so it isn't held inline on `turn()`'s + // already-large frame — `run_single` and the cron wrappers nest more + // layers on top, which would otherwise overflow the stack. + Box::pin(self.run_turn_via_tinyagents_session( + user_message, + &effective_model, temperature, - on_progress: self.on_progress.clone(), - user_message: user_message.to_string(), max_iterations, - }; - let turn_run_queue = self.run_queue.clone(); - let cached_prefix = self.cached_transcript_messages.take(); - // Resolve the context window once per turn through the provider so - // local providers (LM Studio) trim to their runtime-loaded n_ctx - // rather than the trained-max table (#3550 / TAURI-RUST-6V0). - // Must run before `agent: self` takes the &mut borrow below. - // - // For local providers this is always `Some` (a conservative floor - // backs up any missing profile default), so trimming always engages. - // `None` means a cloud provider with an unknown model — trimming is - // intentionally skipped there (large window; over-trimming is worse). - let turn_context_window = self - .provider - .effective_context_window(&effective_model) - .await; - match turn_context_window { - Some(context_window) => tracing::debug!( - provider = %provider_name, - model = %effective_model, - context_window, - "[agent_loop] effective context window resolved for turn" - ), - None => tracing::debug!( - provider = %provider_name, - model = %effective_model, - "[agent_loop] effective context window unavailable (cloud unknown model); pre-dispatch trimming skipped this turn" - ), - } - let mut observer = AgentObserver { - agent: self, - artifact_store, - effective_model: effective_model.clone(), - context_window: turn_context_window, - cumulative_input: 0, - cumulative_output: 0, - cumulative_cached: 0, - cumulative_charged: 0.0, - last_turn_usage: None, - cached_prefix, - pending_results: Vec::new(), - did_push_final: false, - }; - let mut buf: Vec = Vec::new(); - - // Box-pin the parent agent's engine call so its ~600-line - // generator state lives on the heap. Tools that delegate to - // sub-agents (orchestrator → researcher / personality / - // archetype / skill) recurse back into another - // `run_turn_engine` via `run_subagent`; without the box, - // both engines' state machines pile up on the same tokio - // worker stack and overflow the 2 MiB default. The inner - // boxes inside `run_typed_mode` aren't reached if the - // overflow happens during the parent's poll on the way in - // — verified against the `chat-harness-subagent` Playwright - // lane crash on PR #3151. - // Carry the current turn's image placeholders so a delegation to the - // vision sub-agent (analyze_image) can forward the attached image - // into its prompt — the orchestrator's own non-vision turn keeps the - // placeholder as text and never rehydrates it. - let turn_image_placeholders = - crate::openhuman::agent::multimodal::extract_image_placeholders_in_text( - user_message, - ); - let (outcome_result, subagent_usage_entries) = - crate::openhuman::tokenjuice::savings::with_turn_model( - effective_model.clone(), - // Box the per-turn context chain onto the heap so the added - // `with_turn_model` scope does not deepen the worker stack — - // the same stack-accumulation guard the sub-agent path uses - // around `run_turn_engine`. Without this the cron agent-job - // lib test overflows its stack under llvm-cov instrumentation - // (issue #4122 review). - Box::pin( - super::super::super::turn_subagent_usage::with_turn_collector( - super::super::super::turn_attachments_context::with_current_turn_image_placeholders( - turn_image_placeholders, - super::super::super::model_vision_context::with_current_model_vision( - model_vision, - Box::pin(super::super::super::engine::run_turn_engine( - provider.as_ref(), - &mut buf, - &mut tool_source, - &progress, - &mut observer, - &checkpoint, - &parser, - &provider_name, - &effective_model, - temperature, - true, // silent — the channel/UI renders via progress + the return value - &multimodal, - &multimodal_files, - max_iterations, - AGENT_TURN_MAX_OUTPUT_TOKENS, - None, // the web bridge streams via on_progress deltas, not on_delta - &[], - turn_run_queue, - None, // main agent compacts via its ContextManager in before_dispatch - )), - ), - ), - ) - ), - ) - .await; - let outcome = outcome_result?; - - // Pull the observer's accounting out, then drop it to release the - // `&mut self` borrow so the epilogue can use `self`. - let did_push_final = observer.did_push_final; - let mut cumulative_input = observer.cumulative_input; - let mut cumulative_output = observer.cumulative_output; - let mut cumulative_cached = observer.cumulative_cached; - let mut cumulative_charged = observer.cumulative_charged; - let last_turn_usage = observer.last_turn_usage.take(); - drop(observer); - - // Roll any sub-agent spend gathered during this turn into the - // session-level token/cost meters so the UI footer reflects the - // *holistic* cost (parent + delegated children). The global cost - // tracker is fed separately, per provider call, by each sub-agent's - // observer. `subagent_usage_entries` is also forwarded to the - // `chat_done` event for the per-child hover breakdown. - if !subagent_usage_entries.is_empty() { - let mut sub_input = 0u64; - let mut sub_output = 0u64; - let mut sub_cached = 0u64; - let mut sub_charged = 0.0f64; - for entry in &subagent_usage_entries { - sub_input += entry.usage.input_tokens; - sub_output += entry.usage.output_tokens; - sub_cached += entry.usage.cached_input_tokens; - sub_charged += entry.usage.charged_amount_usd; - } - tracing::debug!( - subagents = subagent_usage_entries.len(), - sub_input, - sub_output, - sub_charged, - "[agent_loop] folding sub-agent spend into turn totals" - ); - cumulative_input += sub_input; - cumulative_output += sub_output; - cumulative_cached += sub_cached; - cumulative_charged += sub_charged; - } - - // Capture the turn's holistic totals (parent + sub-agents) so the - // web-channel delivery layer can forward them on `chat_done` for the - // UI footer's session token / cost / context meters. - self.last_turn_usage_totals = Some( - crate::openhuman::agent::harness::turn_subagent_usage::LastTurnUsage { - input_tokens: cumulative_input, - output_tokens: cumulative_output, - cached_input_tokens: cumulative_cached, - cost_usd: cumulative_charged, - context_window: turn_context_window.unwrap_or(0), - subagents: subagent_usage_entries, - }, - ); - let records = std::mem::take(&mut tool_source.records); - - self.context.record_tool_calls(records.len()); - - // Account this turn's tokens (prompt + completion) and elapsed time - // against the thread's active goal, flipping it to budget_limited - // when the cap is crossed. Best-effort — never fails the turn. - crate::openhuman::thread_goals::runtime::account_turn_against_goal( - &self.workspace_dir, - cumulative_input, - cumulative_output, - turn_started.elapsed().as_secs(), - ) - .await; - - // For a clean final response the observer already pushed the - // assistant message + persisted. For a max-iteration checkpoint or - // circuit-breaker halt the engine returned the text without pushing - // it, so finish the history + transcript here (mirrors the old - // final/max-iter branches). - if !did_push_final { - self.history - .push(ConversationMessage::Chat(ChatMessage::assistant( - outcome.text.clone(), - ))); - self.trim_history(); - // Note: the engine already emits `TurnCompleted` on the - // checkpoint exit (and every other terminal path), so we don't - // re-emit it here — doing so would double-fire for the UI. - let messages = self.tool_dispatcher.to_provider_messages(&self.history); - self.persist_session_transcript( - &messages, - cumulative_input, - cumulative_output, - cumulative_cached, - cumulative_charged, - last_turn_usage.as_ref(), - ); - } - - // Auto-save a short memory of the final reply (not on a capped turn, - // matching the prior behavior). - if self.auto_save && outcome.stop != super::super::super::engine::TurnStop::Cap { - let summary = truncate_with_ellipsis(&outcome.text, 100); - let _ = self - .memory - .store("", "assistant_resp", &summary, MemoryCategory::Daily, None) - .await; - } - - // Fire post-turn hooks (non-blocking). - if !self.post_turn_hooks.is_empty() { - let ctx = TurnContext { - user_message: user_message.to_string(), - assistant_response: outcome.text.clone(), - tool_calls: records, - turn_duration_ms: turn_started.elapsed().as_millis() as u64, - session_id: Some(self.event_session_id.clone()) - .filter(|session_id| !session_id.trim().is_empty()), - agent_id: Some(self.agent_definition_id.clone()) - .filter(|agent_id| !agent_id.trim().is_empty()), - entrypoint: Some(self.event_channel.clone()) - .filter(|entrypoint| !entrypoint.trim().is_empty()), - iteration_count: outcome.iterations as usize, - }; - hooks::fire_hooks(&self.post_turn_hooks, ctx); - } - - Ok(outcome.text) + run_super_context, + )) + .await }; // end of `turn_body` async block // Run the turn body inside the parent-execution-context scope so @@ -1002,12 +719,22 @@ impl Agent { turn_stop_hooks.push(std::sync::Arc::new(hook)); } } + // Surface this turn's image-attachment placeholders so a delegation to a + // vision sub-agent (which reads `current_turn_image_placeholders()` in + // `agent_orchestration::tools::dispatch`) can forward the user's attached + // image — the orchestrator itself keeps it as a text placeholder. Scoped + // around the harness turn (the delegating tool fires inside it). + let image_placeholders = + crate::openhuman::agent::multimodal::extract_image_placeholders_in_text(user_message); let result = if turn_stop_hooks.is_empty() { harness::with_parent_context( parent_context, harness::with_agent_context_prepared_sources( agent_context_prepared_sources.clone(), - turn_body, + harness::turn_attachments_context::with_current_turn_image_placeholders( + image_placeholders, + turn_body, + ), ), ) .await @@ -1016,9 +743,12 @@ impl Agent { parent_context, harness::with_agent_context_prepared_sources( agent_context_prepared_sources.clone(), - crate::openhuman::agent::stop_hooks::with_stop_hooks( - turn_stop_hooks, - turn_body, + harness::turn_attachments_context::with_current_turn_image_placeholders( + image_placeholders, + crate::openhuman::agent::stop_hooks::with_stop_hooks( + turn_stop_hooks, + turn_body, + ), ), ), ) @@ -1060,6 +790,287 @@ impl Agent { result } + /// Drive a full chat turn through the `tinyagents` harness (issue #4249). + /// + /// The frozen system+prior history is converted to provider messages, the + /// user turn appended, and the loop run over the agent's resolved tools. The + /// final reply + the user turn are recorded into `history`, the transcript + /// is persisted, and `TurnCompleted` is emitted so the UI stops spinning. + /// + /// Full-fidelity with the legacy `run_turn_engine`: live tool-timeline / + /// text-delta progress and the cost/token footer are mirrored from the + /// harness event stream via the [`OpenhumanEventBridge`] (tinyagents 0.2.0), + /// `[IMAGE:…]`/`[FILE:…]` markers are expanded for the provider, and history + /// is trimmed to the provider's context window. + async fn run_turn_via_tinyagents_session( + &mut self, + user_message: &str, + effective_model: &str, + temperature: f64, + max_iterations: usize, + // Whether the super-context graph node should run this turn (gate decided + // by `should_run_super_context` in `turn()`, before the user row was + // pushed to history — so it can't be recomputed here). + run_super_context: bool, + ) -> Result { + let turn_started = std::time::Instant::now(); + // This turn's stamped user message is already the last entry in + // `self.history` (pushed by `turn()` before the engine branch), so build + // the provider messages straight from history — do NOT push the user + // again. When a cached transcript prefix is present (a resumed session's + // KV-cache warm-up), prepend it and clear it so the first request reuses + // the cached prefix exactly once. + let mut messages = self.tool_dispatcher.to_provider_messages(&self.history); + if let Some(cached) = self.cached_transcript_messages.take() { + // The cached prefix already carries the system prompt + prior + // conversation, so drop the freshly-rendered leading system + // message(s) and append only this turn's new (user) messages. + let tail = messages + .into_iter() + .skip_while(|m| m.role == "system") + .collect::>(); + let mut combined = cached; + combined.extend(tail); + messages = combined; + } + + // Multimodal prep (parity with the legacy engine): rehydrate image + // placeholders for vision-capable providers, then expand `[IMAGE:…]` / + // `[FILE:…]` markers into provider-ready content before dispatch. The + // expanded copy is provider-only and never persisted to `history`. + let multimodal = self + .integration_runtime_config + .as_ref() + .map(|c| c.multimodal.clone()) + .unwrap_or_default(); + let multimodal_files = self + .integration_runtime_config + .as_ref() + .map(|c| c.multimodal_files.clone()) + .unwrap_or_default(); + // Honor custom/BYOK vision models too: they can set `model_vision` even + // when the provider capability bit is false, and must still rehydrate + // `[IMAGE:…]` placeholders (else image chat silently degrades to text). + if (self.provider.supports_vision() || self.model_vision) + && crate::openhuman::agent::multimodal::has_image_placeholders(&messages) + { + messages = crate::openhuman::agent::multimodal::rehydrate_image_placeholders(&messages); + } + let messages = crate::openhuman::agent::multimodal::prepare_messages_for_provider( + &messages, + &multimodal, + &multimodal_files, + ) + .await + .map(|prepared| prepared.messages) + .unwrap_or(messages); + + tracing::info!( + model = %effective_model, + max_iterations, + tools = self.tools.len(), + "[agent_loop] routing chat turn through the tinyagents harness" + ); + + // Resolve the provider's effective context window so the harness can + // trim long threads to budget (autocompaction parity). + let context_window = self + .provider + .effective_context_window(effective_model) + .await; + + // Dispatch through the chat turn graph (this folder's `graph.rs`): a thin + // wrapper over the shared tinyagents seam that pins the chat path's fixed + // arguments (no child scope, no early-exit tools, graceful cap pause, + // per-turn output cap) and runs the context-window summarization step. + // Context middlewares sourced from this session's ContextManager: the + // per-tool-result byte cap + payload summarizer (after_tool), the + // cache-align warning and microcompact tool-body clearing (before_model). + let context_mw = crate::openhuman::tinyagents::TurnContextMiddleware { + tool_result_budget_bytes: self.context.tool_result_budget_bytes(), + payload_summarizer: self.payload_summarizer.clone(), + cache_align: self.context.compaction_enabled(), + microcompact_keep_recent: self.context.microcompact_keep_recent(), + // Honor the [context].enabled / autocompact_enabled opt-outs: when off, + // the summarization middleware is not installed (no summarizer tokens, + // no history rewrite). + autocompact_enabled: self.context.autocompact_enabled(), + // Super context (first-turn read-only context collection) as a graph + // node — enabled only when its gate passed above. The node runs the + // scout on the first model call and folds the bundle into the message. + super_context: run_super_context.then(|| { + crate::openhuman::tinyagents::SuperContextConfig { + user_message: user_message.to_string(), + } + }), + }; + + // Gather any sub-agent spend delegated during this turn (synchronous + // `spawn_subagent` runs inline on this task and records into the collector) + // so the turn's usage meters + the `chat_done` per-child breakdown include + // it — the collector scope the legacy engine installed. + let (outcome, subagent_usage_entries) = + crate::openhuman::agent::harness::turn_subagent_usage::with_turn_collector( + super::graph::run_chat_turn_graph(super::graph::ChatTurnGraph { + provider: self.provider.clone(), + model: effective_model.to_string(), + temperature, + messages, + tools: self.tools.clone(), + visible_tool_names: self.visible_tool_names.clone(), + max_iterations, + on_progress: self.on_progress.clone(), + context_window, + run_queue: self.run_queue.clone(), + context_mw, + }), + ) + .await; + let outcome = outcome?; + + // The stamped user turn is already in `self.history` (pushed by `turn()`), + // so append only the structured messages this turn produced — assistant + // tool calls + tool results + (for a clean finish) the final assistant — + // preserving tool-call history fidelity for the UI, persisted transcript, + // and the next turn's KV-cache prefix. + self.history.extend(outcome.conversation.iter().cloned()); + + // Token accounting for the turn (the cap checkpoint call below folds in + // its own usage). + // Seed from the turn outcome (the harness observed real usage incl. cached + // tokens and an estimated cost) rather than zero, so a normal non-cap turn + // persists real cost instead of $0. The cap-checkpoint branch below folds + // in its extra call's usage on top. + let mut input_tokens = outcome.input_tokens; + let mut output_tokens = outcome.output_tokens; + let mut cached_input_tokens = outcome.cached_input_tokens; + let mut charged_amount_usd = outcome.charged_amount_usd; + + let reply = if outcome.hit_cap { + // The loop paused at the tool-call cap. Ask the model for a resumable + // checkpoint (tools disabled), falling back to a deterministic + // done/next summary so the thread never ends on a dangling tool + // cycle. Fold the extra call's usage into the turn accounting. + let base = self.tool_dispatcher.to_provider_messages(&self.history); + let (summary, summary_usage) = self + .summarize_iteration_checkpoint( + &base, + effective_model, + outcome.model_calls as u32 + 1, + ) + .await; + if let Some(u) = summary_usage { + input_tokens += u.input_tokens; + output_tokens += u.output_tokens; + cached_input_tokens += u.cached_input_tokens; + charged_amount_usd += u.charged_amount_usd; + } + let checkpoint = if summary.trim().is_empty() { + super::super::turn_checkpoint::build_deterministic_checkpoint( + &tool_records_from_conversation(&outcome.conversation), + max_iterations, + ) + } else { + summary + }; + self.history + .push(ConversationMessage::Chat(ChatMessage::assistant( + checkpoint.clone(), + ))); + checkpoint + } else if outcome.text.trim().is_empty() && outcome.tool_calls == 0 { + // A completion with no text and no tool calls is never a valid final + // answer — surface it as an error instead of wedging the thread on a + // blank reply (bug-report-2026-05-26 A1, defect B). + return Err(anyhow::Error::new( + crate::openhuman::agent::error::AgentError::EmptyProviderResponse { + iteration: outcome.model_calls, + }, + )); + } else { + outcome.text.clone() + }; + self.trim_history(); + + // Fold this turn's sub-agent spend into the cumulative meters and capture + // the holistic per-turn usage the web channel surfaces on `chat_done` (it + // calls `take_last_turn_usage_totals()` right after the turn). Without this + // the event reported `usage: None` despite the transcript being persisted + // with real numbers. + for entry in &subagent_usage_entries { + input_tokens = input_tokens.saturating_add(entry.usage.input_tokens); + output_tokens = output_tokens.saturating_add(entry.usage.output_tokens); + cached_input_tokens = + cached_input_tokens.saturating_add(entry.usage.cached_input_tokens); + charged_amount_usd += entry.usage.charged_amount_usd; + } + self.last_turn_usage_totals = Some( + crate::openhuman::agent::harness::turn_subagent_usage::LastTurnUsage { + input_tokens, + output_tokens, + cached_input_tokens, + cost_usd: charged_amount_usd, + context_window: context_window.unwrap_or(0), + subagents: subagent_usage_entries, + }, + ); + + let persisted = self.tool_dispatcher.to_provider_messages(&self.history); + self.persist_session_transcript( + &persisted, + input_tokens, + output_tokens, + cached_input_tokens, + charged_amount_usd, + None, + ); + + // Charge this turn's usage against the thread's active goal (parity with + // the legacy engine) so budgeted goals progress to `budget_limited` and + // continuation scheduling reads a live budget. Self-guarding + best-effort + // — a no-op when there is no active goal for the ambient thread. + crate::openhuman::thread_goals::runtime::account_turn_against_goal( + &self.workspace_dir, + input_tokens, + output_tokens, + turn_started.elapsed().as_secs(), + ) + .await; + + self.emit_progress(AgentProgress::TurnCompleted { + iterations: outcome.model_calls as u32, + }) + .await; + + if self.auto_save { + let summary = truncate_with_ellipsis(&reply, 100); + let _ = self + .memory + .store("", "assistant_resp", &summary, MemoryCategory::Daily, None) + .await; + } + + // Fire post-turn hooks (non-blocking), matching the legacy engine. + if !self.post_turn_hooks.is_empty() { + let ctx = TurnContext { + user_message: user_message.to_string(), + assistant_response: reply.clone(), + tool_calls: tool_records_from_conversation(&outcome.conversation), + turn_duration_ms: turn_started.elapsed().as_millis() as u64, + session_id: Some(self.event_session_id.clone()) + .filter(|session_id| !session_id.trim().is_empty()), + agent_id: Some(self.agent_definition_id.clone()) + .filter(|agent_id| !agent_id.trim().is_empty()), + entrypoint: Some(self.event_channel.clone()) + .filter(|entrypoint| !entrypoint.trim().is_empty()), + iteration_count: outcome.model_calls, + }; + hooks::fire_hooks(&self.post_turn_hooks, ctx); + } + + Ok(reply) + } + pub(super) async fn inject_agent_experience_context( &self, user_message: &str, @@ -1221,10 +1232,7 @@ impl Agent { #[cfg(test)] mod super_context_gate_tests { - use super::{ - parse_context_bundle_has_enough_context, render_agent_context_status_note, - should_run_super_context, - }; + use super::{render_agent_context_status_note, should_run_super_context}; use crate::openhuman::agent::harness::AgentContextPreparedSource; #[test] @@ -1290,26 +1298,4 @@ mod super_context_gate_tests { assert!(note.contains("super context preparation")); assert!(note.contains("Do not call `agent_prepare_context` again")); } - - #[test] - fn parses_context_bundle_sufficiency() { - assert_eq!( - parse_context_bundle_has_enough_context( - "[context_bundle]\nhas_enough_context: true\n[/context_bundle]" - ), - Some(true) - ); - assert_eq!( - parse_context_bundle_has_enough_context( - "[context_bundle]\nHAS_ENOUGH_CONTEXT: false\n[/context_bundle]" - ), - Some(false) - ); - assert_eq!( - parse_context_bundle_has_enough_context( - "[context_bundle]\nsummary: ok\n[/context_bundle]" - ), - None - ); - } } diff --git a/src/openhuman/agent/harness/session/turn/graph.rs b/src/openhuman/agent/harness/session/turn/graph.rs new file mode 100644 index 000000000..571f7c765 --- /dev/null +++ b/src/openhuman/agent/harness/session/turn/graph.rs @@ -0,0 +1,107 @@ +//! The **chat turn graph** (issue #4249). +//! +//! Per the per-folder `graph.rs` convention, this module owns the chat folder's +//! graph definition, its available tools, and its summarization step — all thin +//! over the shared tinyagents seam +//! ([`run_turn_via_tinyagents_shared`](crate::openhuman::tinyagents::run_turn_via_tinyagents_shared)). +//! +//! **Graph.** The top-level interactive chat turn: a single agent-loop turn +//! driven by the tinyagents harness, observed via the session's `on_progress` +//! sink (live tool timeline, streaming text deltas, cost/token footer) and +//! steerable mid-flight through the session run queue. The loop pauses gracefully +//! at the model-call cap so [`core`](super::core) can emit a resumable checkpoint +//! instead of erroring. +//! +//! **Available tools.** The agent's resolved harness tool set (`tools`), +//! advertised via [`SharedToolAdapter`](crate::openhuman::tinyagents::SharedToolAdapter) +//! and filtered by `visible_tool_names`. The chat turn surfaces clarifying +//! questions inline rather than pausing, so it advertises **no early-exit +//! tools**. +//! +//! **Summarization.** The caller resolves the model's effective context window +//! and passes it as `context_window`, so the shared seam installs the +//! context-window summarization step (`tinyagents::summarize`) ahead of the +//! deterministic front-trim. + +use std::collections::HashSet; +use std::sync::Arc; + +use anyhow::Result; +use tokio::sync::mpsc::Sender; + +use crate::openhuman::agent::harness::run_queue::RunQueue; +use crate::openhuman::agent::progress::AgentProgress; +use crate::openhuman::inference::provider::{ChatMessage, Provider, AGENT_TURN_MAX_OUTPUT_TOKENS}; +use crate::openhuman::tinyagents::{ + run_turn_via_tinyagents_shared, TinyagentsTurnOutcome, TurnContextMiddleware, +}; +use crate::openhuman::tools::Tool; + +/// Inputs for a single chat-turn graph dispatch. Grouped into a struct so the +/// thin entry point stays readable (the shared seam takes 14 positional args); +/// each field maps to the chat path's variable inputs while the fixed chat-path +/// arguments (no child scope, no early-exit tools, graceful cap pause, per-turn +/// output cap) are applied inside [`run_chat_turn_graph`]. +pub(crate) struct ChatTurnGraph { + /// The session provider (already cloned by the caller). + pub provider: Arc, + /// The effective model id for this turn. + pub model: String, + /// Sampling temperature. + pub temperature: f64, + /// Provider-ready messages (system + prior history + this turn's user turn, + /// multimodal markers already expanded). + pub messages: Vec, + /// The agent's resolved, `Arc`-shared harness tool set. + pub tools: Arc>>, + /// Callable-tool whitelist (empty = every visible tool). + pub visible_tool_names: HashSet, + /// Model-call cap for the loop. + pub max_iterations: usize, + /// Session progress sink — mirrors the harness event stream onto + /// `AgentProgress` when `Some`. + pub on_progress: Option>, + /// Resolved context window, driving the summarization step. `None` when the + /// provider does not advertise a window. + pub context_window: Option, + /// Session run queue for mid-flight steering. + pub run_queue: Option>, + /// openhuman context middlewares (cache-align, microcompact, tool-output + /// budget + payload summarizer) sourced from the session's `ContextManager`. + pub context_mw: TurnContextMiddleware, +} + +/// Drive the chat turn graph: a thin wrapper over the shared tinyagents seam +/// that pins the chat path's fixed arguments. Returns the turn outcome +/// ([`core`](super::core) folds usage, persists the conversation, and handles a +/// cap-hit checkpoint). +pub(crate) async fn run_chat_turn_graph(graph: ChatTurnGraph) -> Result { + run_turn_via_tinyagents_shared( + graph.provider, + &graph.model, + graph.temperature, + graph.messages, + vec![graph.tools], + graph.visible_tool_names, + graph.max_iterations, + // Mirror the harness event stream onto this session's progress sink. + graph.on_progress, + // Top-level chat turn — no child-progress attribution. + None, + graph.context_window, + // Mid-flight steering from the session's run queue. + graph.run_queue, + // The top-level chat turn surfaces clarifying questions inline rather + // than pausing the loop, so no early-exit tools here. + &[], + // Pause gracefully at the model-call cap so the turn emits a resumable + // checkpoint instead of erroring or returning a dangling tool cycle. + true, + // Bound the main agent's per-call output (legacy parity — the engine + // capped every turn at `AGENT_TURN_MAX_OUTPUT_TOKENS`). + Some(AGENT_TURN_MAX_OUTPUT_TOKENS), + // Context middlewares sourced from the session's ContextManager. + graph.context_mw, + ) + .await +} diff --git a/src/openhuman/agent/harness/session/turn/mod.rs b/src/openhuman/agent/harness/session/turn/mod.rs index 47625d919..0d64398e6 100644 --- a/src/openhuman/agent/harness/session/turn/mod.rs +++ b/src/openhuman/agent/harness/session/turn/mod.rs @@ -3,6 +3,7 @@ mod context; mod core; +mod graph; mod session_io; mod tools; diff --git a/src/openhuman/agent/harness/session/turn_engine_adapter.rs b/src/openhuman/agent/harness/session/turn_engine_adapter.rs deleted file mode 100644 index 060e41d81..000000000 --- a/src/openhuman/agent/harness/session/turn_engine_adapter.rs +++ /dev/null @@ -1,561 +0,0 @@ -//! Engine seams for the stateful `Agent::turn`. -//! -//! These adapt the `Agent` to the shared [`run_turn_engine`] so web/desktop -//! chat runs the same loop as every other entry point, while preserving the -//! Agent's richer state: typed `ConversationMessage` history (with structured -//! tool calls + round-tripped `reasoning_content`), the `ContextManager` -//! reduction chain, KV-cache transcript prefixes, transcript persistence, and -//! the pluggable `ToolDispatcher` (incl. PFormat). -//! -//! * [`AgentToolSource`] owns `Arc`/value clones of the Agent's tool state -//! (disjoint from the `&mut Agent` the observer holds) and runs each call -//! through the shared [`run_agent_tool_call`], collecting `ToolCallRecord`s. -//! * [`AgentObserver`] borrows the `Agent` mutably: it runs the context -//! reduction + re-materializes the engine's `ChatMessage` buffer from the -//! typed history each iteration, rebuilds the typed history from the engine's -//! per-iteration callbacks, accumulates usage, and persists the transcript. -//! * [`AgentCheckpoint`] summarizes the turn-so-far into a resumable checkpoint -//! when the iteration cap is hit (mirrors `summarize_iteration_checkpoint`). - -use std::collections::HashSet; -use std::sync::Arc; - -use anyhow::Result; -use async_trait::async_trait; - -use super::agent_tool_exec::{run_agent_tool_call, AgentToolExecCtx}; -use super::transcript; -use super::turn_checkpoint::MAX_ITER_CHECKPOINT_INSTRUCTION; -use super::types::Agent; -use crate::openhuman::agent::dispatcher::{ - ParsedToolCall as DispatcherParsedToolCall, ToolDispatcher, ToolExecutionResult, -}; -use crate::openhuman::agent::harness::engine::{ - CheckpointOutcome, CheckpointStrategy, ProgressReporter, ToolRunResult, ToolSource, - TurnObserver, -}; -use crate::openhuman::agent::harness::parse::ParsedToolCall; -use crate::openhuman::agent::harness::payload_summarizer::PayloadSummarizer; -use crate::openhuman::agent::harness::tool_result_artifacts::{ - spill_aggregate_tool_results, ToolResultArtifactStore, -}; -use crate::openhuman::agent::hooks::ToolCallRecord; -use crate::openhuman::agent::progress::AgentProgress; -use crate::openhuman::agent::tool_policy::ToolPolicy; -use crate::openhuman::agent_tool_policy::ToolPolicySession; -use crate::openhuman::context::ReductionOutcome; -use crate::openhuman::inference::provider::{ - ChatMessage, ChatRequest, ConversationMessage, Provider, ProviderDelta, ToolCall, UsageInfo, - AGENT_TURN_MAX_OUTPUT_TOKENS, -}; -use crate::openhuman::tools::{Tool, ToolSpec}; - -/// Rebuild the persisted `Vec` for an assistant-with-tools history -/// entry: prefer the provider's native calls, else synthesise from the parsed -/// calls (mirrors `Agent::persisted_tool_calls_for_history`). -fn persisted_tool_calls( - native: &[ToolCall], - parsed: &[ParsedToolCall], - results: &[ToolExecutionResult], - iteration: usize, -) -> Vec { - if !native.is_empty() { - return native.to_vec(); - } - // Synthesise from the parsed calls, reusing the *exact* id each result was - // recorded under (`results[i].tool_call_id`) so the persisted assistant - // tool-call id matches its `ToolResults` entry — what the next provider - // turn (and history-fidelity tests) rely on. - parsed - .iter() - .enumerate() - .map(|(idx, c)| { - let id = results - .get(idx) - .and_then(|r| r.tool_call_id.clone()) - .or_else(|| c.id.clone()) - .unwrap_or_else(|| format!("parsed-{}-{}", iteration + 1, idx + 1)); - ToolCall { - id, - name: c.name.clone(), - arguments: c.arguments.to_string(), - // Prompt-parsed calls carry no provider extra_content; the - // native (Gemini) path returns early above, preserving it. - extra_content: None, - } - }) - .collect() -} - -/// Tool source for `Agent::turn`. Owns clones of the Agent's tool state so it -/// doesn't borrow the `Agent` (which [`AgentObserver`] holds mutably). -pub(super) struct AgentToolSource { - pub tools: Arc>>, - pub visible_tool_names: HashSet, - pub tool_policy_session: ToolPolicySession, - pub tool_policy: Arc, - pub payload_summarizer: Option>, - pub event_session_id: String, - pub event_channel: String, - pub agent_definition_id: String, - pub prefer_markdown: bool, - pub budget_bytes: usize, - /// Stage 1a kill-switch. Constant for the session, so (unlike the tool - /// surface) it is set once at construction and never re-synced. - pub compaction_enabled: bool, - /// Agent-level TokenJuice profile. Constant for the session. - pub tokenjuice_compression: crate::openhuman::tokenjuice::AgentTokenjuiceCompression, - pub artifact_store: Option, - pub should_send_specs: bool, - pub advertised_specs: Vec, - /// Collected per-call records, drained by the post-loop epilogue for hooks. - pub records: Vec, -} - -#[async_trait] -impl ToolSource for AgentToolSource { - fn request_specs(&self) -> &[ToolSpec] { - if self.should_send_specs { - &self.advertised_specs - } else { - &[] - } - } - - async fn execute_call( - &mut self, - call: &ParsedToolCall, - iteration: usize, - progress: &dyn ProgressReporter, - _progress_call_id: &str, - ) -> ToolRunResult { - // `run_agent_tool_call` takes the dispatcher's `ParsedToolCall` shape; - // convert from the engine's internal one. - let dispatcher_call = DispatcherParsedToolCall { - name: call.name.clone(), - arguments: call.arguments.clone(), - tool_call_id: call.id.clone(), - }; - let ctx = AgentToolExecCtx { - tools: &self.tools, - visible_tool_names: &self.visible_tool_names, - tool_policy_session: &self.tool_policy_session, - tool_policy: self.tool_policy.as_ref(), - payload_summarizer: self.payload_summarizer.as_deref(), - event_session_id: &self.event_session_id, - event_channel: &self.event_channel, - agent_definition_id: &self.agent_definition_id, - prefer_markdown: self.prefer_markdown, - budget_bytes: self.budget_bytes, - compaction_enabled: self.compaction_enabled, - tokenjuice_compression: self.tokenjuice_compression, - artifact_store: self.artifact_store.as_ref(), - }; - let (exec_result, record) = - run_agent_tool_call(&ctx, progress, &dispatcher_call, iteration).await; - self.records.push(record); - ToolRunResult { - text: exec_result.output, - success: exec_result.success, - } - } - - fn sync_agent_surface( - &mut self, - tools: Arc>>, - visible_tool_names: HashSet, - tool_policy_session: ToolPolicySession, - payload_summarizer: Option>, - prefer_markdown: bool, - budget_bytes: usize, - should_send_specs: bool, - advertised_specs: Vec, - ) { - self.tools = tools; - self.visible_tool_names = visible_tool_names; - self.tool_policy_session = tool_policy_session; - self.payload_summarizer = payload_summarizer; - self.prefer_markdown = prefer_markdown; - self.budget_bytes = budget_bytes; - self.should_send_specs = should_send_specs; - self.advertised_specs = advertised_specs; - } -} - -/// Turn observer for `Agent::turn`: owns the typed-history rebuild, context -/// management, usage accounting, and transcript persistence. -pub(super) struct AgentObserver<'a> { - pub agent: &'a mut Agent, - pub artifact_store: Option, - pub effective_model: String, - /// Effective context window (tokens) for `effective_model`, resolved once - /// per turn via the provider so local providers (e.g. LM Studio) trim to - /// their *runtime-loaded* `n_ctx` rather than the model's trained maximum - /// (#3550 / Sentry TAURI-RUST-6V0). `None` → skip pre-dispatch trimming. - pub context_window: Option, - pub cumulative_input: u64, - pub cumulative_output: u64, - pub cumulative_cached: u64, - pub cumulative_charged: f64, - pub last_turn_usage: Option, - /// Cached transcript prefix for KV-cache reuse on a resumed session, - /// consumed on the first iteration. - pub cached_prefix: Option>, - /// Tool results buffered during the per-call loop, flushed to typed history - /// via the dispatcher's `format_results` once the assistant turn lands. - pub pending_results: Vec, - /// Whether the engine reported a clean final response (so the post-loop - /// epilogue knows not to push `outcome.text` itself). - pub did_push_final: bool, -} - -impl AgentObserver<'_> { - fn persist(&mut self) { - let messages = self - .agent - .tool_dispatcher - .to_provider_messages(&self.agent.history); - self.agent.persist_session_transcript( - &messages, - self.cumulative_input, - self.cumulative_output, - self.cumulative_cached, - self.cumulative_charged, - self.last_turn_usage.as_ref(), - ); - } -} - -#[async_trait] -impl TurnObserver for AgentObserver<'_> { - async fn before_dispatch( - &mut self, - buf: &mut Vec, - tools: &mut dyn crate::openhuman::agent::harness::engine::ToolSource, - iteration: usize, - ) -> Result<()> { - if self.agent.drain_composio_integrations_changed_events() { - let refreshed = self - .agent - .refresh_delegation_tools_from_cached_integrations("event"); - if refreshed { - log::debug!( - "[agent_loop] midturn:resync-delegation-tools — composio integrations changed; resyncing tool surface (iteration={} visible_tools={})", - iteration, - self.agent.visible_tool_names.len() - ); - tools.sync_agent_surface( - Arc::clone(&self.agent.tools), - self.agent.visible_tool_names.clone(), - self.agent.tool_policy_session.clone(), - self.agent.payload_summarizer.clone(), - self.agent.context.prefer_markdown_tool_output(), - self.agent.context.tool_result_budget_bytes(), - self.agent.tool_dispatcher.should_send_tool_specs(), - self.agent.visible_tool_specs.as_ref().clone(), - ); - } - } - - // Pre-dispatch token-budget trim on the typed history. - if let Some(context_window) = self.context_window { - super::super::token_budget::trim_conversation_history_to_budget( - &mut self.agent.history, - context_window, - ); - } - // Global context-management reduction chain. - let outcome = self - .agent - .context - .reduce_before_call(&mut self.agent.history) - .await?; - if let ReductionOutcome::Exhausted { - utilisation_pct, - reason, - } = &outcome - { - return Err(anyhow::anyhow!( - "Context window exhausted ({utilisation_pct}% full): {reason}" - )); - } - - // Re-materialize the engine's ChatMessage buffer from the typed - // history. On the first iteration of a resumed session, splice the - // byte-identical cached prefix + the new user-message tail for KV-cache - // reuse; otherwise rebuild from scratch. - let messages = if let Some(mut cached) = self.cached_prefix.take() { - let tail = self.agent.tool_dispatcher.to_provider_messages( - &self.agent.history[self.agent.history.len().saturating_sub(1)..], - ); - cached.extend(tail); - cached - } else { - self.agent - .tool_dispatcher - .to_provider_messages(&self.agent.history) - }; - *buf = messages; - // Second-pass trim on the materialized provider messages (mirrors the - // legacy `Agent::turn`, which trimmed both the typed history and the - // built `ChatMessage` list). - if let Some(context_window) = self.context_window { - super::super::token_budget::trim_chat_messages_to_budget(buf, context_window); - } - Ok(()) - } - - fn allow_empty_final(&self) -> bool { - false - } - - fn record_usage(&mut self, provider: &str, model: &str, usage: &UsageInfo) { - self.agent.context.record_usage(usage); - crate::openhuman::cost::record_provider_usage(model, usage); - // Effective per-call cost: the backend-charged amount when the provider - // echoes one, else the per-model catalog estimate (#4124). Using this - // instead of raw `charged_amount_usd` means BYO/local providers that - // never bill a charge still contribute a priced cost to the session - // total. `cumulative_charged` is therefore the *net cost*, not strictly - // the backend charge. - let call_cost = crate::openhuman::agent::cost::call_cost_usd(model, usage); - self.cumulative_input += usage.input_tokens; - self.cumulative_output += usage.output_tokens; - self.cumulative_cached += usage.cached_input_tokens; - self.cumulative_charged += call_cost; - self.last_turn_usage = Some(transcript::TurnUsage { - provider: provider.to_string(), - model: model.to_string(), - usage: transcript::MessageUsage { - input: usage.input_tokens, - output: usage.output_tokens, - cached_input: usage.cached_input_tokens, - context_window: usage.context_window, - cost_usd: call_cost, - }, - ts: chrono::Utc::now().to_rfc3339(), - reasoning_content: None, - tool_calls: Vec::new(), - iteration: 0, - }); - } - - async fn on_assistant( - &mut self, - display_text: &str, - _response_text: &str, - reasoning_content: Option<&str>, - native_tool_calls: &[ToolCall], - parsed_calls: &[ParsedToolCall], - iteration: usize, - is_final: bool, - ) { - if is_final { - let mut assistant_msg = ChatMessage::assistant(display_text.to_string()); - if let Some(rc) = reasoning_content { - assistant_msg.extra_metadata = Some(serde_json::json!({ "reasoning_content": rc })); - } - let mut turn_usage = None; - if let Some(ref mut usage) = self.last_turn_usage { - usage.reasoning_content = reasoning_content - .map(str::trim) - .filter(|s| !s.is_empty()) - .map(ToString::to_string); - usage.tool_calls = native_tool_calls.to_vec(); - usage.iteration = (iteration + 1) as u32; - turn_usage = Some(usage.clone()); - } - if let Some(turn_usage) = turn_usage.as_ref() { - transcript::attach_turn_usage_metadata(&mut assistant_msg, turn_usage); - } - self.agent - .history - .push(ConversationMessage::Chat(assistant_msg)); - self.agent.trim_history(); - self.did_push_final = true; - return; - } - - // Assistant turn with tool calls. Mirror `Agent::turn` exactly: push the - // pre-tool narrative text (if any) as a standalone Chat message, then - // the structured AssistantToolCalls, then the dispatcher-formatted - // results buffered during the per-call loop. - if !display_text.is_empty() { - self.agent - .history - .push(ConversationMessage::Chat(ChatMessage::assistant( - display_text.to_string(), - ))); - } - let tool_calls = persisted_tool_calls( - native_tool_calls, - parsed_calls, - &self.pending_results, - iteration, - ); - if let Some(ref mut usage) = self.last_turn_usage { - usage.reasoning_content = reasoning_content - .map(str::trim) - .filter(|s| !s.is_empty()) - .map(ToString::to_string); - usage.tool_calls = tool_calls.clone(); - usage.iteration = (iteration + 1) as u32; - } - let extra_metadata = self - .last_turn_usage - .as_ref() - .and_then(transcript::turn_usage_extra_metadata); - self.agent - .history - .push(ConversationMessage::AssistantToolCalls { - text: if display_text.is_empty() { - None - } else { - Some(display_text.to_string()) - }, - tool_calls, - reasoning_content: reasoning_content - .map(str::trim) - .filter(|s| !s.is_empty()) - .map(ToString::to_string), - extra_metadata, - }); - let mut results = std::mem::take(&mut self.pending_results); - spill_aggregate_tool_results( - &mut results, - self.artifact_store.as_ref(), - self.agent.context.tool_result_budget_bytes(), - ) - .await; - let formatted = self.agent.tool_dispatcher.format_results(&results); - self.agent.history.push(formatted); - self.agent.trim_history(); - } - - fn on_tool_result( - &mut self, - call_id: &str, - tool_name: &str, - result_text: &str, - success: bool, - _iteration: usize, - ) { - self.pending_results.push(ToolExecutionResult { - name: tool_name.to_string(), - output: result_text.to_string(), - success, - tool_call_id: Some(call_id.to_string()), - }); - } - - fn after_iteration(&mut self, _buf: &[ChatMessage], _iteration: usize) { - self.persist(); - } -} - -/// Max-iteration checkpoint for `Agent::turn`: summarize the turn's tool digest -/// into a resumable checkpoint (streaming text deltas through the progress -/// sink), with a deterministic fallback. -pub(super) struct AgentCheckpoint { - pub provider: Arc, - pub dispatcher: Arc, - pub model: String, - pub temperature: f64, - pub on_progress: Option>, - pub user_message: String, - pub max_iterations: usize, -} - -#[async_trait] -impl CheckpointStrategy for AgentCheckpoint { - async fn on_max_iter(&self, digest: &str, max_iterations: usize) -> Result { - let deterministic = format!( - "I reached the tool-call limit for this turn ({max_iterations} steps), so I paused here.\n\n\ - **Done so far:**\n{digest}\n\ - **Next steps:** I'll continue from here — just reply (e.g. \"continue\") and I'll pick up \ - where I left off." - ); - let mut messages = vec![ChatMessage::user(format!( - "You were working on this user request:\n{}\n\nHere are the tool calls you made this turn \ - and their results — compile your checkpoint from these:\n{}", - self.user_message, digest - ))]; - messages.push(ChatMessage::user(MAX_ITER_CHECKPOINT_INSTRUCTION)); - - let checkpoint_iteration = (self.max_iterations + 1) as u32; - // Stream the checkpoint prose as text deltas (tools disabled). - let (delta_tx_opt, delta_forwarder) = if self.on_progress.is_some() { - let (tx, mut rx) = tokio::sync::mpsc::channel::(128); - let progress_tx = self.on_progress.clone(); - let forwarder = tokio::spawn(async move { - while let Some(event) = rx.recv().await { - let Some(ref sink) = progress_tx else { - continue; - }; - if let ProviderDelta::TextDelta { delta } = event { - if sink - .send(AgentProgress::TextDelta { - delta, - iteration: checkpoint_iteration, - }) - .await - .is_err() - { - break; - } - } - } - }); - (Some(tx), Some(forwarder)) - } else { - (None, None) - }; - - let result = self - .provider - .chat( - ChatRequest { - messages: &messages, - tools: None, - stream: delta_tx_opt.as_ref(), - // Reservation-pricing pre-flight budget cap (TAURI-RUST-C62). - max_tokens: Some(AGENT_TURN_MAX_OUTPUT_TOKENS), - }, - &self.model, - self.temperature, - ) - .await; - drop(delta_tx_opt); - if let Some(handle) = delta_forwarder { - let _ = handle.await; - } - - match result { - Ok(resp) => { - let usage = resp.usage.clone(); - // Strip any stray tool-call markup; keep only prose. - let (text, calls) = self.dispatcher.parse_response(&resp); - let checkpoint = if !text.trim().is_empty() { - text - } else if calls.is_empty() { - resp.text.unwrap_or_default() - } else { - String::new() - }; - let text = if checkpoint.trim().is_empty() { - deterministic - } else { - checkpoint - }; - Ok(CheckpointOutcome { text, usage }) - } - Err(e) => { - log::warn!("[agent_loop] checkpoint summary call failed: {e:#}"); - Ok(CheckpointOutcome { - text: deterministic, - usage: None, - }) - } - } - } -} diff --git a/src/openhuman/agent/harness/session/types.rs b/src/openhuman/agent/harness/session/types.rs index ca401dcd8..8b07e00ac 100644 --- a/src/openhuman/agent/harness/session/types.rs +++ b/src/openhuman/agent/harness/session/types.rs @@ -367,14 +367,6 @@ pub struct AgentBuilder { /// `config.learning.episodic_capture_enabled` is true. Used to call /// `flush_open_segment` at the closest available session-end signal. pub(super) archivist_hook: Option>, - /// Phase 1.5 — when `true` AND `archivist_hook` is `Some`, the - /// `ContextManager`'s summarizer is wrapped with a - /// `SegmentRecapSummarizer` that routes compaction through the - /// archivist's rolling segment recap (one summarizer, soft-fallback). - /// When `false` (or archivist absent), the plain `ProviderSummarizer` - /// is used and Phase 1.5 is completely absent from the hot path. - /// Default: `true` (mirrors `LearningConfig::unified_compaction_enabled`). - pub(super) unified_compaction_enabled: bool, } impl Default for AgentBuilder { diff --git a/src/openhuman/agent/harness/session_queue.rs b/src/openhuman/agent/harness/session_queue.rs deleted file mode 100644 index 8a9be7b65..000000000 --- a/src/openhuman/agent/harness/session_queue.rs +++ /dev/null @@ -1,158 +0,0 @@ -//! Per-session serialised lane queue. -//! -//! All incoming tasks are serialised per-session to prevent race conditions when -//! writing to files, memory, or other shared resources. Cross-session requests -//! run concurrently. - -use std::collections::HashMap; -use std::sync::Arc; -use tokio::sync::{Mutex, OwnedSemaphorePermit, Semaphore}; - -/// A queue that serialises work within a session while allowing parallelism -/// across sessions. -/// -/// Each session ID maps to a `Semaphore(1)`. Acquiring the permit blocks -/// subsequent requests for the *same* session until the permit is released. -pub struct SessionQueue { - lanes: Mutex>>, -} - -impl SessionQueue { - pub fn new() -> Self { - Self { - lanes: Mutex::new(HashMap::new()), - } - } - - /// Acquire the lane for `session_id`. - /// - /// Returns an `OwnedSemaphorePermit` that the caller must hold for the - /// duration of the request. Subsequent requests on the same session will - /// block until this permit is dropped. - pub async fn acquire(&self, session_id: &str) -> OwnedSemaphorePermit { - let sem = { - let mut map = self.lanes.lock().await; - let is_new = !map.contains_key(session_id); - let sem = map - .entry(session_id.to_string()) - .or_insert_with(|| Arc::new(Semaphore::new(1))) - .clone(); - if is_new { - tracing::trace!("[session-queue] created lane for session={session_id}"); - } - tracing::trace!( - "[session-queue] acquiring lane session={session_id}, permits={}", - sem.available_permits() - ); - sem - }; - let permit = sem.acquire_owned().await.expect("session semaphore closed"); - tracing::trace!("[session-queue] acquired lane for session={session_id}"); - permit - } - - /// Remove stale session lanes that have no waiters. - /// Call periodically or after sessions end to prevent unbounded growth. - pub async fn gc(&self) { - let mut map = self.lanes.lock().await; - let before = map.len(); - map.retain(|id, sem| { - let keep = sem.available_permits() < 1 || Arc::strong_count(sem) > 1; - if !keep { - tracing::trace!("[session-queue] pruning idle lane session={id}"); - } - keep - }); - let removed = before - map.len(); - if removed > 0 { - tracing::debug!( - "[session-queue] gc removed {removed} idle lane(s), {} remaining", - map.len() - ); - } - } - - /// Number of tracked session lanes (for diagnostics). - pub async fn lane_count(&self) -> usize { - self.lanes.lock().await.len() - } -} - -impl Default for SessionQueue { - fn default() -> Self { - Self::new() - } -} - -#[cfg(test)] -mod tests { - use super::*; - use std::sync::atomic::{AtomicUsize, Ordering}; - use tokio::time::{sleep, Duration}; - - #[tokio::test] - async fn serialises_within_same_session() { - let queue = Arc::new(SessionQueue::new()); - let counter = Arc::new(AtomicUsize::new(0)); - - let mut handles = Vec::new(); - for _ in 0..5 { - let q = queue.clone(); - let c = counter.clone(); - handles.push(tokio::spawn(async move { - let _permit = q.acquire("session-1").await; - // If serialised, at most 1 task holds the permit at a time. - let prev = c.fetch_add(1, Ordering::SeqCst); - // While we hold the permit, sleep briefly. - sleep(Duration::from_millis(10)).await; - let current = c.load(Ordering::SeqCst); - // Nobody else should have incremented while we held the permit. - assert_eq!(current, prev + 1); - })); - } - - for h in handles { - h.await.unwrap(); - } - assert_eq!(counter.load(Ordering::SeqCst), 5); - } - - #[tokio::test] - async fn parallel_across_sessions() { - let queue = Arc::new(SessionQueue::new()); - let active = Arc::new(AtomicUsize::new(0)); - let max_active = Arc::new(AtomicUsize::new(0)); - - let mut handles = Vec::new(); - for i in 0..4 { - let q = queue.clone(); - let a = active.clone(); - let m = max_active.clone(); - let session = format!("session-{i}"); - handles.push(tokio::spawn(async move { - let _permit = q.acquire(&session).await; - let current = a.fetch_add(1, Ordering::SeqCst) + 1; - m.fetch_max(current, Ordering::SeqCst); - sleep(Duration::from_millis(50)).await; - a.fetch_sub(1, Ordering::SeqCst); - })); - } - - for h in handles { - h.await.unwrap(); - } - // Multiple sessions should have run concurrently. - assert!(max_active.load(Ordering::SeqCst) > 1); - } - - #[tokio::test] - async fn gc_removes_idle_lanes() { - let queue = SessionQueue::new(); - { - let _permit = queue.acquire("temp-session").await; - } - // Permit dropped, lane is idle. - queue.gc().await; - assert_eq!(queue.lane_count().await, 0); - } -} diff --git a/src/openhuman/agent/harness/subagent_runner/ops/graph.rs b/src/openhuman/agent/harness/subagent_runner/ops/graph.rs new file mode 100644 index 000000000..572c76cbc --- /dev/null +++ b/src/openhuman/agent/harness/subagent_runner/ops/graph.rs @@ -0,0 +1,783 @@ +//! The **sub-agent turn graph** (issue #4249). +//! +//! Per the per-folder `graph.rs` convention, this module owns the sub-agent +//! folder's graph definition, its available tools, and its summarization step — +//! all thin over the shared tinyagents seam +//! ([`run_turn_via_tinyagents_shared`]). +//! +//! **Graph.** A single agent-loop turn driven by the tinyagents harness: the +//! model is called, requested tools run, and the loop repeats until the model +//! returns without further tool calls or the iteration budget is exhausted. The +//! canonical sub-agent turn path (the legacy `run_inner_loop` / `run_turn_engine` +//! are removed); `run_typed_mode` calls it unconditionally. +//! +//! **Available tools.** The sub-agent reuses the parent's harness tools plus the +//! per-spawn dynamic tools, advertised via [`SharedToolAdapter`] over the shared +//! `Arc>>` tool sets (`[dynamic_tools, parent_tools]` — dynamic +//! first so a shadowing dynamic tool executes, matching advertisement), filtered +//! by `allowed_names`. `ask_user_clarification` is the early-exit tool. +//! +//! **Summarization.** When the sub-agent model's effective context window is +//! known, the shared seam installs the context-window summarization step +//! (`tinyagents::summarize`) ahead of the deterministic front-trim — see +//! [`run_subagent_via_graph`], which resolves the window before dispatch. +//! +//! It mirrors the original seams: child progress deltas (`Subagent*` events incl. +//! thinking), mid-flight steering, the `ask_user_clarification` early-exit pause, +//! and a graceful model-call-cap checkpoint summary +//! (`SubagentCheckpoint::on_max_iter`). + +use std::collections::HashSet; +use std::sync::Arc; + +use super::usage::AggregatedUsage; +use crate::openhuman::agent::harness::subagent_runner::types::SubagentRunError; +use crate::openhuman::agent::progress::AgentProgress; +use crate::openhuman::inference::provider::{ChatMessage, ConversationMessage, Provider}; +use crate::openhuman::tinyagents::{run_turn_via_tinyagents_shared, SubagentScope}; +use crate::openhuman::tools::{Tool, ToolSpec}; + +/// Drive a sub-agent turn on the tinyagents harness. Returns +/// `(text, model_calls, AggregatedUsage, early_exit_tool, hit_cap)` — `hit_cap` +/// is `true` when the run stopped at the model-call cap with work still pending +/// (the caller surfaces this as `SubagentRunStatus::Incomplete`, #4096). +#[allow(clippy::too_many_arguments)] +pub(super) async fn run_subagent_via_graph( + provider: Arc, + model: &str, + temperature: f64, + history: &mut Vec, + parent_tools: Arc>>, + dynamic_tools: Vec>, + specs: Vec, + allowed_names: HashSet, + max_iterations: usize, + run_queue: Option>, + on_progress: Option>, + agent_id: &str, + task_id: &str, + extended_policy: bool, + worker_thread_id: Option, + workspace_dir: std::path::PathBuf, + max_output_tokens: u32, + model_vision: bool, +) -> Result<(String, usize, AggregatedUsage, Option, bool), SubagentRunError> { + tracing::info!( + model, + max_iterations, + agent_id, + task_id, + model_vision, + observed = on_progress.is_some(), + "[subagent_runner:graph] routing sub-agent turn through tinyagents harness" + ); + // `specs` is derived from the registry inside the runner; the tinyagents + // adapters advertise each tool via its own `spec()`, so it's unused here. + let _ = &specs; + + // Vision forwarding (parity with the legacy `run_inner_loop`): rehydrate + // `[IMAGE:…]` placeholders in the sub-agent's history when either the + // provider advertises vision or the sub-agent model is user-flagged as + // vision-capable (BYOK/custom). The expanded copy is provider-only — the + // persisted `history` written back below keeps the original markers. + let dispatch_history = if (provider.supports_vision() || model_vision) + && crate::openhuman::agent::multimodal::has_image_placeholders(history) + { + crate::openhuman::agent::multimodal::rehydrate_image_placeholders(history) + } else { + history.clone() + }; + + // Child-progress attribution: when the parent carries an `on_progress` sink, + // mirror this sub-agent's iterations / tool calls / text + thinking deltas as + // `Subagent*` events scoped to (`agent_id`, `task_id`) so the parent thread + // can nest them under the live subagent row. + let subagent_scope = on_progress.as_ref().map(|_| SubagentScope { + agent_id: agent_id.to_string(), + task_id: task_id.to_string(), + extended_policy, + }); + + // Keep a provider handle for the cap-hit summary call (the run consumes the + // other clone). + let summary_provider = provider.clone(); + + // Resolve the sub-agent model's effective context window so the harness runs + // the context-window summarization step (issue #4249) on sub-agent turns too. + // A long-running / resumed sub-agent (worker threads, durable sessions) can + // accumulate a transcript past its own window; summarize before each model + // call rather than relying solely on the parent's one-time trim. + let context_window = provider.effective_context_window(model).await; + + // A sub-agent turn runs *nested inside* the parent agent's turn (parent + // harness → spawn_subagent tool → here), so the child's full + // `run_turn_via_tinyagents_shared` future would otherwise sit on the parent's + // poll stack. Heap-allocate it (as the legacy `run_inner_loop` did) so the + // parent+child harness drives don't overflow the stack. + // Capture native-tool support before `provider` is moved: the durable-history + // append below serializes this turn's typed suffix with the matching dispatcher. + let native_tools = provider.supports_native_tools(); + let mut outcome = Box::pin(run_turn_via_tinyagents_shared( + provider, + model, + temperature, + dispatch_history, + // Dynamic (per-spawn) tools first so a dynamic tool that intentionally + // shadows a parent-registry tool of the same name is the one that + // *executes* — matching the advertisement order (`dedup_tool_specs_by_name` + // lists dynamic specs before parent specs in `runner.rs`). The shared + // adapter resolves a name by scanning the sets in order, so a + // parent-first order would run the parent impl for a shadowed name. + vec![Arc::new(dynamic_tools), parent_tools], + allowed_names, + max_iterations, + // Parent's progress sink — child events ride it, scoped below. + on_progress, + subagent_scope, + // Resolved above — drives the sub-agent context-window summarization step. + context_window, + // Mid-flight steering: forward queued steer messages into the run. + run_queue, + // Pause + checkpoint when the child asks the user a clarifying question. + &["ask_user_clarification"], + // Pause gracefully at the model-call cap so we can summarize a resumable + // checkpoint (below) instead of erroring — legacy `on_max_iter` parity. + true, + // Bound the sub-agent's per-call output at its configured budget. + Some(max_output_tokens), + // Context middlewares: cache-align + default tool-result byte cap so a + // sub-agent's (often large) tool outputs stay bounded in its transcript. + crate::openhuman::tinyagents::TurnContextMiddleware::defaults(), + )) + .await + .map_err(SubagentRunError::Provider)?; + + // Write the final conversation back so the caller can checkpoint / persist. + // Keep the original (un-expanded) prior turns and append only this turn's typed + // suffix, serialized with the matching dispatcher so a native tool round + // persists as the `{content, tool_calls}` / `{tool_call_id, content}` envelope + // (re-parsed by `convert::chat_message_to_message` next turn) instead of an + // assistant with no `tool_calls` followed by an orphan `tool` row. Appending + // the typed `outcome.conversation` (messages-since-last-user) also avoids + // indexing a post-trim `outcome.history` with the pre-trim length, and the + // durable `[IMAGE:…]` markers stay put since the prior user turns are untouched. + use crate::openhuman::agent::dispatcher::ToolDispatcher; + let suffix = if native_tools { + crate::openhuman::agent::dispatcher::NativeToolDispatcher + .to_provider_messages(&outcome.conversation) + } else { + crate::openhuman::agent::dispatcher::XmlToolDispatcher + .to_provider_messages(&outcome.conversation) + }; + history.extend(suffix); + + let mut usage = AggregatedUsage { + input_tokens: outcome.input_tokens, + output_tokens: outcome.output_tokens, + // Carry the child's cached-prefix tokens + estimated cost (the turn + // outcome now reports both) so sub-agent spend rolls into the parent + // instead of being recorded as uncached and $0. + cached_input_tokens: outcome.cached_input_tokens, + charged_amount_usd: outcome.charged_amount_usd, + }; + + // Cap hit with work still pending: summarize the run-so-far into a resumable + // checkpoint (the delegating agent continues from partial progress) rather + // than surfacing an empty/partial answer — the legacy `SubagentCheckpoint`. + if outcome.hit_cap { + use super::super::super::engine::CheckpointStrategy; + let digest = build_cap_digest(&outcome.conversation); + let strategy = super::checkpoint::SubagentCheckpoint { + provider: summary_provider.as_ref(), + model: model.to_string(), + temperature, + agent_id: agent_id.to_string(), + // The checkpoint summary call's output cap — the standard per-turn + // budget (the value this field replaced when it was hardcoded). + max_output_tokens: crate::openhuman::inference::provider::AGENT_TURN_MAX_OUTPUT_TOKENS, + }; + match strategy.on_max_iter(&digest, max_iterations).await { + Ok(co) => { + if let Some(u) = co.usage { + usage.input_tokens += u.input_tokens; + usage.output_tokens += u.output_tokens; + } + outcome.text = co.text; + } + Err(e) => return Err(SubagentRunError::Provider(e)), + } + } + + // Mirror this turn's conversation to the spawn's worker thread (when one is + // attached), matching the legacy `SubagentObserver`: assistant intents + + // final answer as `agent` messages, tool results as `user` messages. The + // initial user prompt was already written when the worker thread was created. + if let Some(thread_id) = worker_thread_id { + mirror_worker_thread( + &workspace_dir, + &thread_id, + agent_id, + task_id, + &outcome.conversation, + // On a cap/early-exit, `outcome.text` is the checkpoint/question that + // replaced (or stands in for) a final assistant turn. + if outcome.hit_cap || outcome.early_exit_tool.is_some() { + Some(outcome.text.as_str()) + } else { + None + }, + ); + } + + // On an early-exit (`ask_user_clarification`), `outcome.text` is the question + // and the runner checkpoints + returns AwaitingUser. `None` = ran to a final + // answer (or a cap-hit checkpoint summary). + Ok(( + outcome.text, + outcome.model_calls, + usage, + outcome.early_exit_tool, + outcome.hit_cap, + )) +} + +/// Mirror a sub-agent turn's structured conversation to its worker thread, +/// matching the legacy [`SubagentObserver`]: assistant turns (intents + final) +/// become `agent` messages, tool results become `user` messages. `extra_final`, +/// when set, is appended as a trailing `agent` message (the cap checkpoint or +/// clarifying question, which isn't a plain assistant turn in the transcript). +fn mirror_worker_thread( + workspace_dir: &std::path::Path, + thread_id: &str, + agent_id: &str, + task_id: &str, + conversation: &[ConversationMessage], + extra_final: Option<&str>, +) { + use crate::openhuman::memory_conversations::{ + append_message, ConversationMessage as StoredMessage, + }; + + let mut append = |content: String, sender: &str| { + let message = StoredMessage { + id: format!("{sender}:{}", uuid::Uuid::new_v4()), + content, + message_type: "text".to_string(), + extra_metadata: serde_json::json!({ + "scope": "worker_thread", + "agent_id": agent_id, + "task_id": task_id, + }), + sender: sender.to_string(), + created_at: chrono::Utc::now().to_rfc3339(), + }; + if let Err(err) = append_message(workspace_dir.to_path_buf(), thread_id, message) { + tracing::debug!( + agent_id, + thread_id, + error = %err, + "[subagent_runner:graph] failed to append worker-thread message" + ); + } + }; + + for msg in conversation { + match msg { + ConversationMessage::AssistantToolCalls { text, .. } => { + if let Some(t) = text.as_deref().filter(|t| !t.trim().is_empty()) { + append(t.to_string(), "agent"); + } + } + ConversationMessage::ToolResults(results) => { + for r in results { + append(r.content.clone(), "user"); + } + } + ConversationMessage::Chat(c) if c.role == "assistant" => { + if !c.content.trim().is_empty() { + append(c.content.clone(), "agent"); + } + } + _ => {} + } + } + + if let Some(text) = extra_final.filter(|t| !t.trim().is_empty()) { + append(text.to_string(), "agent"); + } +} + +/// Build the `tool → outcome` digest the cap-hit summary call summarizes, in the +/// legacy `- {name} [{ok|failed}]: {output}` format (engine `run_tool_digest`), +/// pairing each tool result back to its call by id. Tool success isn't carried +/// on the converted transcript, so results are reported optimistically as `ok`. +fn build_cap_digest(conversation: &[ConversationMessage]) -> String { + use std::collections::HashMap; + use std::fmt::Write as _; + + // call_id -> tool name, from this turn's assistant tool-call rounds. + let mut names: HashMap<&str, &str> = HashMap::new(); + for msg in conversation { + if let ConversationMessage::AssistantToolCalls { tool_calls, .. } = msg { + for call in tool_calls { + names.insert(call.id.as_str(), call.name.as_str()); + } + } + } + + let mut out = String::new(); + for msg in conversation { + if let ConversationMessage::ToolResults(results) = msg { + for r in results { + let name = names + .get(r.tool_call_id.as_str()) + .copied() + .unwrap_or("tool"); + let body = crate::openhuman::util::truncate_with_ellipsis(&r.content, 800); + let _ = writeln!(out, "- {name} [ok]: {body}"); + } + } + } + out.trim_end().to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::openhuman::inference::provider::{ChatResponse, ToolCall}; + use crate::openhuman::tools::ToolResult; + use async_trait::async_trait; + use std::sync::atomic::{AtomicUsize, Ordering}; + + struct EchoTool; + #[async_trait] + impl Tool for EchoTool { + fn name(&self) -> &str { + "echo" + } + fn description(&self) -> &str { + "echo" + } + fn parameters_schema(&self) -> serde_json::Value { + serde_json::json!({"type": "object"}) + } + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + let m = args.get("msg").and_then(|v| v.as_str()).unwrap_or(""); + Ok(ToolResult::success(format!("echoed:{m}"))) + } + } + + struct TwoStepProvider { + calls: AtomicUsize, + } + #[async_trait] + impl Provider for TwoStepProvider { + async fn chat_with_system( + &self, + _s: Option<&str>, + _m: &str, + _model: &str, + _t: f64, + ) -> anyhow::Result { + Ok(String::new()) + } + async fn chat( + &self, + _r: crate::openhuman::inference::provider::ChatRequest<'_>, + _model: &str, + _t: f64, + ) -> anyhow::Result { + let n = self.calls.fetch_add(1, Ordering::SeqCst); + if n == 0 { + Ok(ChatResponse { + tool_calls: vec![ToolCall { + id: "1".to_string(), + name: "echo".to_string(), + arguments: r#"{"msg":"hi"}"#.to_string(), + extra_content: None, + }], + ..Default::default() + }) + } else { + Ok(ChatResponse { + text: Some("all done".to_string()), + ..Default::default() + }) + } + } + fn supports_native_tools(&self) -> bool { + true + } + } + + #[tokio::test] + async fn subagent_runs_through_the_graph_engine_with_real_tools() { + let provider = Arc::new(TwoStepProvider { + calls: AtomicUsize::new(0), + }); + let parent_tools: Arc>> = Arc::new(vec![Box::new(EchoTool)]); + let mut allowed = HashSet::new(); + allowed.insert("echo".to_string()); + let mut history = vec![ChatMessage::user("please echo hi")]; + + let (output, iterations, usage, early_exit, hit_cap) = run_subagent_via_graph( + provider, + "mock-model", + 0.0, + &mut history, + parent_tools, + vec![], + vec![], + allowed, + 10, + None, + None, + "researcher", + "task-1", + false, + None, + std::env::temp_dir(), + 1024, + false, + ) + .await + .expect("graph subagent runs"); + + assert_eq!(output, "all done"); + assert_eq!(iterations, 2); + assert!(early_exit.is_none()); + assert!(!hit_cap, "a clean finish should not report a cap hit"); + let _ = usage; + // History was written back: user + assistant(tool) + tool result + assistant(final). + assert!(history.len() >= 4); + assert!(history.iter().any(|m| m.content.contains("echoed:hi"))); + } + + /// A provider that streams visible text + reasoning through the request's + /// delta sender, exercising the child-progress bridge end to end. + struct ThinkingStreamProvider; + #[async_trait] + impl Provider for ThinkingStreamProvider { + async fn chat_with_system( + &self, + _s: Option<&str>, + _m: &str, + _model: &str, + _t: f64, + ) -> anyhow::Result { + Ok(String::new()) + } + async fn chat( + &self, + r: crate::openhuman::inference::provider::ChatRequest<'_>, + _model: &str, + _t: f64, + ) -> anyhow::Result { + use crate::openhuman::inference::provider::ProviderDelta; + if let Some(tx) = r.stream { + let _ = tx + .send(ProviderDelta::ThinkingDelta { + delta: "let me think".into(), + }) + .await; + for chunk in ["Hel", "lo"] { + let _ = tx + .send(ProviderDelta::TextDelta { + delta: chunk.into(), + }) + .await; + } + } + Ok(ChatResponse { + text: Some("Hello".to_string()), + ..Default::default() + }) + } + fn supports_native_tools(&self) -> bool { + true + } + } + + #[tokio::test] + async fn child_text_and_thinking_deltas_are_scoped_to_the_subagent() { + let (tx, mut rx) = tokio::sync::mpsc::channel::(64); + let parent_tools: Arc>> = Arc::new(vec![]); + let mut history = vec![ChatMessage::user("hi")]; + + let (output, _iters, _usage, _early, _hit_cap) = run_subagent_via_graph( + Arc::new(ThinkingStreamProvider), + "mock-model", + 0.0, + &mut history, + parent_tools, + vec![], + vec![], + HashSet::new(), + 4, + None, + Some(tx), + "researcher", + "task-7", + false, + None, + std::env::temp_dir(), + 1024, + false, + ) + .await + .expect("child-delta subagent runs"); + + assert_eq!(output, "Hello"); + + let mut text = String::new(); + let mut thinking = String::new(); + let mut saw_iter = false; + while let Ok(p) = rx.try_recv() { + match p { + AgentProgress::SubagentTextDelta { delta, task_id, .. } => { + assert_eq!(task_id, "task-7"); + text.push_str(&delta); + } + AgentProgress::SubagentThinkingDelta { + delta, agent_id, .. + } => { + assert_eq!(agent_id, "researcher"); + thinking.push_str(&delta); + } + AgentProgress::SubagentIterationStarted { task_id, .. } => { + assert_eq!(task_id, "task-7"); + saw_iter = true; + } + // The parent-scoped variants must never appear on a child run. + AgentProgress::TextDelta { .. } + | AgentProgress::ThinkingDelta { .. } + | AgentProgress::IterationStarted { .. } => { + panic!("child run emitted a parent-scoped progress event"); + } + _ => {} + } + } + assert!(saw_iter, "a SubagentIterationStarted should be emitted"); + assert!( + text.contains("Hello"), + "child text deltas should reassemble, got {text:?}" + ); + assert!( + thinking.contains("let me think"), + "child thinking deltas should be forwarded, got {thinking:?}" + ); + } + + /// A tool named like the early-exit tool that echoes its `question` arg. + struct AskTool; + #[async_trait] + impl Tool for AskTool { + fn name(&self) -> &str { + "ask_user_clarification" + } + fn description(&self) -> &str { + "ask the user a clarifying question" + } + fn parameters_schema(&self) -> serde_json::Value { + serde_json::json!({"type": "object", "properties": {"question": {"type": "string"}}}) + } + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + let q = args + .get("question") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + Ok(ToolResult::success(q)) + } + } + + /// A provider whose first turn calls `ask_user_clarification`; a second turn + /// would answer, but the early-exit pause should stop the loop before it. + struct AskThenAnswer { + calls: AtomicUsize, + } + #[async_trait] + impl Provider for AskThenAnswer { + async fn chat_with_system( + &self, + _s: Option<&str>, + _m: &str, + _model: &str, + _t: f64, + ) -> anyhow::Result { + Ok(String::new()) + } + async fn chat( + &self, + _r: crate::openhuman::inference::provider::ChatRequest<'_>, + _model: &str, + _t: f64, + ) -> anyhow::Result { + let n = self.calls.fetch_add(1, Ordering::SeqCst); + if n == 0 { + Ok(ChatResponse { + tool_calls: vec![ToolCall { + id: "ask-1".to_string(), + name: "ask_user_clarification".to_string(), + arguments: r#"{"question":"which file?"}"#.to_string(), + extra_content: None, + }], + ..Default::default() + }) + } else { + Ok(ChatResponse { + text: Some("should not be reached".to_string()), + ..Default::default() + }) + } + } + fn supports_native_tools(&self) -> bool { + true + } + } + + #[tokio::test] + async fn ask_user_clarification_pauses_and_surfaces_the_question() { + let provider = Arc::new(AskThenAnswer { + calls: AtomicUsize::new(0), + }); + let parent_tools: Arc>> = Arc::new(vec![Box::new(AskTool)]); + let mut allowed = HashSet::new(); + allowed.insert("ask_user_clarification".to_string()); + let mut history = vec![ChatMessage::user("help me")]; + + let (output, iterations, _usage, early_exit, _hit_cap) = run_subagent_via_graph( + provider.clone(), + "mock-model", + 0.0, + &mut history, + parent_tools, + vec![], + vec![], + allowed, + 10, + None, + None, + "researcher", + "task-9", + false, + None, + std::env::temp_dir(), + 1024, + false, + ) + .await + .expect("ask-clarification subagent runs"); + + // The loop paused after the tool round: the early-exit tool is surfaced + // and the question is the returned text — the second model turn never ran. + assert_eq!(early_exit.as_deref(), Some("ask_user_clarification")); + assert_eq!(output, "which file?"); + assert_eq!( + iterations, 1, + "the loop should pause before a second model call" + ); + assert_eq!(provider.calls.load(Ordering::SeqCst), 1); + } + + /// A tool that always succeeds, so the loop keeps going until the cap. + struct NoopTool; + #[async_trait] + impl Tool for NoopTool { + fn name(&self) -> &str { + "noop" + } + fn description(&self) -> &str { + "no-op" + } + fn parameters_schema(&self) -> serde_json::Value { + serde_json::json!({"type": "object"}) + } + async fn execute(&self, _a: serde_json::Value) -> anyhow::Result { + Ok(ToolResult::success("ok")) + } + } + + /// A provider that never finishes: every tool-enabled turn asks for `noop`. + /// A request with no tools is the cap-hit summary call — it returns prose. + struct LoopForeverProvider; + #[async_trait] + impl Provider for LoopForeverProvider { + async fn chat_with_system( + &self, + _s: Option<&str>, + _m: &str, + _model: &str, + _t: f64, + ) -> anyhow::Result { + Ok(String::new()) + } + async fn chat( + &self, + r: crate::openhuman::inference::provider::ChatRequest<'_>, + _model: &str, + _t: f64, + ) -> anyhow::Result { + if r.tools.is_some() { + Ok(ChatResponse { + tool_calls: vec![ToolCall { + id: "n".to_string(), + name: "noop".to_string(), + arguments: "{}".to_string(), + extra_content: None, + }], + ..Default::default() + }) + } else { + // The summary call (tools=None): return a progress checkpoint. + Ok(ChatResponse { + text: Some("progress: explored two leads".to_string()), + ..Default::default() + }) + } + } + fn supports_native_tools(&self) -> bool { + true + } + } + + #[tokio::test] + async fn cap_hit_summarizes_a_resumable_checkpoint() { + let parent_tools: Arc>> = Arc::new(vec![Box::new(NoopTool)]); + let mut allowed = HashSet::new(); + allowed.insert("noop".to_string()); + let mut history = vec![ChatMessage::user("do a big task")]; + + let (output, iterations, _usage, early_exit, hit_cap) = run_subagent_via_graph( + Arc::new(LoopForeverProvider), + "mock-model", + 0.0, + &mut history, + parent_tools, + vec![], + vec![], + allowed, + 2, + None, + None, + "researcher", + "task-cap", + false, + None, + std::env::temp_dir(), + 1024, + false, + ) + .await + .expect("cap-hit subagent runs"); + + // The loop paused at the 2-call budget and summarized instead of erroring. + assert!(early_exit.is_none()); + assert!(hit_cap, "reaching the model-call cap should report hit_cap"); + assert_eq!(iterations, 2, "the loop should stop at the model-call cap"); + assert!( + output.contains("progress: explored two leads"), + "cap hit should return the summary checkpoint, got {output:?}" + ); + } +} diff --git a/src/openhuman/agent/harness/subagent_runner/ops/loop_.rs b/src/openhuman/agent/harness/subagent_runner/ops/loop_.rs deleted file mode 100644 index 68d457d7c..000000000 --- a/src/openhuman/agent/harness/subagent_runner/ops/loop_.rs +++ /dev/null @@ -1,274 +0,0 @@ -//! Sub-agent inner tool-call loop. -//! -//! Drives the iterative cycle of provider calls and tool execution until the -//! model returns without further tool calls (or the iteration budget is -//! exhausted). Unlike the main agent loop, this is isolated and returns only -//! the final text to be synthesised by the parent. - -use std::collections::HashSet; - -use crate::openhuman::agent::harness::engine::TurnStop; -use crate::openhuman::agent::harness::fork_context::ParentExecutionContext; -use crate::openhuman::agent::harness::subagent_runner::handoff::ResultHandoffCache; -use crate::openhuman::agent::harness::subagent_runner::types::SubagentRunError; -use crate::openhuman::inference::provider::Provider; -use crate::openhuman::tools::{Tool, ToolSpec}; - -use super::super::tool_prep::build_text_mode_tool_instructions; -use super::checkpoint::SubagentCheckpoint; -use super::observer::SubagentObserver; -use super::provider::LazyToolkitResolver; -use super::tool_source::SubagentToolSource; - -/// Cumulative usage stats gathered across all provider calls in the loop. -#[derive(Debug, Clone, Default)] -pub(super) struct AggregatedUsage { - pub(super) input_tokens: u64, - pub(super) output_tokens: u64, - pub(super) cached_input_tokens: u64, - pub(super) charged_amount_usd: f64, -} - -/// The sub-agent's private tool-execution engine. -/// -/// This function drives the iterative cycle of: -/// 1. Sending messages to the provider. -/// 2. Parsing the provider's response for tool calls. -/// 3. Executing tools (with sandboxing and timeouts). -/// 4. Appending results to history and looping until a final response is found. -/// -/// Unlike the main agent loop, this is isolated and returns only the final text -/// to be synthesized by the parent. -#[allow(clippy::too_many_arguments)] -pub(super) async fn run_inner_loop( - provider: &dyn Provider, - history: &mut Vec, - parent_tools: &[Box], - extra_tools: Vec>, - tool_specs: &[ToolSpec], - allowed_names: HashSet, - lazy_resolver: Option, - model: &str, - // User-configured vision flag for `model` (computed at the call site), set as - // the `current_model_vision` task-local around the engine call so a flagged - // custom/BYOK sub-agent model can forward images. - model_vision: bool, - temperature: f64, - max_iterations: usize, - max_output_tokens: u32, - task_id: &str, - agent_id: &str, - worker_thread_id: Option, - handoff_cache: Option<&ResultHandoffCache>, - parent: &ParentExecutionContext, - extended_policy: bool, - tokenjuice_compression: crate::openhuman::tokenjuice::AgentTokenjuiceCompression, - // Optional steering channel. When `Some`, the child engine drains - // steer/collect messages at iteration boundaries so the parent can - // `steer_subagent` a running async sub-agent. `None` = non-steerable. - run_queue: Option>, -) -> Result<(String, usize, AggregatedUsage, Option, TurnStop), SubagentRunError> { - // An autonomous skill run (set via `with_autonomous_iter_cap`) lifts the - // per-agent cap so sub-agents run until done / the circuit breaker trips. - let max_iterations = super::super::autonomous::autonomous_iter_cap() - .map(|cap| cap.max(max_iterations)) - .unwrap_or(max_iterations) - .max(1); - - // Sub-agent transcript stem — computed once up front so every iteration's - // persist resolves to the same file: `{parent_chain}__{unix_ts}_{agent_id}`. - let child_session_key = { - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default(); - let unix_ts = now.as_secs(); - let nanos = now.subsec_nanos(); - let sanitized: String = agent_id - .chars() - .map(|c| { - if c.is_ascii_alphanumeric() || c == '_' || c == '-' { - c - } else { - '_' - } - }) - .collect(); - let task_suffix: String = task_id - .chars() - .filter(|c| c.is_ascii_alphanumeric() || *c == '_' || *c == '-') - .take(12) - .collect(); - if task_suffix.is_empty() { - format!("{unix_ts}_{nanos:09}_{sanitized}") - } else { - format!("{unix_ts}_{nanos:09}_{sanitized}_{task_suffix}") - } - }; - let transcript_stem = { - let parent_chain = match parent.session_parent_prefix.as_deref() { - Some(prefix) => format!("{}__{}", prefix, parent.session_key), - None => parent.session_key.clone(), - }; - format!("{parent_chain}__{child_session_key}") - }; - - // ── Text-mode override for integrations_agent ── - // Large Composio toolkits compile into provider grammars that blow the - // 65 535-rule ceiling, so for `integrations_agent` we omit `tools: [...]` - // and describe them in the system prompt as prose, parsing `` - // tags out of the model's response. Forcing `request_specs() == &[]` makes - // the engine skip native tools and fall back to its XML parse + batched - // `[Tool results]` path — exactly what text mode needs. - let force_text_mode = agent_id == "integrations_agent" && !tool_specs.is_empty(); - if force_text_mode { - if let Some(sys) = history.iter_mut().find(|m| m.role == "system") { - sys.content.push_str("\n\n"); - sys.content - .push_str(&build_text_mode_tool_instructions(tool_specs)); - } - tracing::info!( - task_id = %task_id, - agent_id = %agent_id, - tool_count = tool_specs.len(), - "[subagent_runner:text-mode] omitting tools from API request, injected XML tool protocol into system prompt" - ); - } - - let advertised_specs: Vec = if force_text_mode { - Vec::new() - } else { - tool_specs.to_vec() - }; - - let mut tool_source = SubagentToolSource { - parent_tools, - extra_tools, - allowed_names, - lazy_resolver, - advertised_specs, - handoff_cache, - policy: crate::openhuman::tools::policy::DefaultToolPolicy, - agent_id: agent_id.to_string(), - tokenjuice_compression, - }; - let mut observer = SubagentObserver { - worker_thread_id, - workspace_dir: parent.workspace_dir.clone(), - transcript_stem, - agent_id: agent_id.to_string(), - task_id: task_id.to_string(), - force_text_mode, - usage: AggregatedUsage::default(), - last_turn_usage: None, - }; - let checkpoint = SubagentCheckpoint { - provider, - model: model.to_string(), - temperature, - agent_id: agent_id.to_string(), - max_output_tokens, - }; - let progress = super::super::super::engine::SubagentProgress { - sink: parent.on_progress.clone(), - agent_id: agent_id.to_string(), - task_id: task_id.to_string(), - extended_policy, - }; - - let parser = super::super::super::engine::DefaultParser; - - // Sub-agents have no typed `ContextManager`, so opt the shared turn engine - // into LLM autocompaction: when the context guard reports the window is - // filling, the engine summarizes the flat `ChatMessage` history in place - // (protecting the leading system prompt + recent tail) instead of only - // hard-trimming the oldest messages. Gated on the same `context` config the - // main chat uses, so disabling autocompaction disables it everywhere. - let autocompact = subagent_autocompact_config().await; - - // Heap-allocate the child `run_turn_engine` state machine. Sub-agents - // run as nested polls inside the *parent* agent's `run_turn_engine` - // (the orchestrator → tool exec → `dispatch_subagent` → `run_subagent` - // chain), so without the box the parent's tokio worker poll stack - // also has to carry the child engine's ~600-line generator. That - // crosses the 2 MiB tokio worker default and aborts with - // "thread 'tokio-rt-worker' has overflowed its stack" — see the - // `chat-harness-subagent` Playwright lane crash logged here: - // `[subagent_runner] dispatching agent_id=researcher ... → fatal - // runtime error: stack overflow`. Boxing here breaks the stack - // accumulation at the recursion boundary. Smoke-tested in - // `nested_subagent_dispatch_runs_on_a_constrained_worker_stack`; - // the deep end-to-end catcher is the `chat-harness-subagent` - // Playwright spec. - let outcome = crate::openhuman::tokenjuice::savings::with_turn_model( - model.to_string(), - // Box the context chain so the added `with_turn_model` scope keeps the - // nested sub-agent future off the constrained worker stack (mirrors the - // existing `Box::pin` guard below; issue #4122 review). - Box::pin( - super::super::super::model_vision_context::with_current_model_vision( - model_vision, - Box::pin(super::super::super::engine::run_turn_engine( - provider, - history, - &mut tool_source, - &progress, - &mut observer, - &checkpoint, - &parser, - "subagent", - model, - temperature, - true, // silent — sub-agents never echo to stdout - &crate::openhuman::config::MultimodalConfig::default(), - &crate::openhuman::config::MultimodalFileConfig::default(), - max_iterations, - max_output_tokens, - None, // sub-agents don't stream a draft - &["ask_user_clarification"], - run_queue, // steering channel for `steer_subagent` (None = non-steerable) - autocompact.as_ref(), - )), - ), - ), - ) - .await?; - - Ok(( - outcome.text, - outcome.iterations as usize, - observer.usage, - outcome.early_exit_tool, - outcome.stop, - )) -} - -/// Build the sub-agent's engine-autocompaction config from the global -/// `context` settings, or `None` when context management / autocompaction is -/// disabled (in which case the engine falls back to hard token-budget trim). -/// -/// Reuses the same `enabled` + `autocompact_enabled` toggles and -/// `summarizer_model` override as the main chat, so the two paths stay in sync. -async fn subagent_autocompact_config() -> Option { - let config = match crate::openhuman::config::Config::load_or_init().await { - Ok(cfg) => cfg, - Err(err) => { - tracing::warn!( - error = %err, - "[subagent_runner] failed to load config; engine autocompaction disabled" - ); - return None; - } - }; - let ctx = &config.context; - if !ctx.enabled || !ctx.autocompact_enabled { - tracing::debug!( - enabled = ctx.enabled, - autocompact_enabled = ctx.autocompact_enabled, - "[subagent_runner] engine autocompaction disabled by context config" - ); - return None; - } - Some(crate::openhuman::context::EngineAutocompact::with_defaults( - ctx.summarizer_model.clone(), - )) -} diff --git a/src/openhuman/agent/harness/subagent_runner/ops/mod.rs b/src/openhuman/agent/harness/subagent_runner/ops/mod.rs index f58939db5..5484f9c6c 100644 --- a/src/openhuman/agent/harness/subagent_runner/ops/mod.rs +++ b/src/openhuman/agent/harness/subagent_runner/ops/mod.rs @@ -1,10 +1,11 @@ -//! Sub-agent execution entry points and the inner tool-call loop. +//! Sub-agent execution entry points. //! //! The public runner lives in [`run_subagent`]. It dispatches to //! [`runner::run_typed_mode`] (narrow prompt + filtered tools) which builds a //! brand-new system prompt and a filtered tool list for the requested -//! archetype, then drives provider calls and tool execution until the model -//! returns without further tool calls (or the iteration budget is exhausted). +//! archetype, then drives the turn through the tinyagents harness +//! ([`graph::run_subagent_via_graph`]) until the model returns without +//! further tool calls (or the iteration budget is exhausted). //! //! ## Layout //! @@ -13,20 +14,18 @@ //! | `provider.rs` | `resolve_subagent_provider`, `user_is_signed_in_to_composio`, `LazyToolkitResolver` | //! | `prompt.rs` | Role-contract suffix, `append_subagent_role_contract`, `dedup_tool_specs_by_name` | //! | `runner.rs` | `run_subagent`, `run_typed_mode` | -//! | `loop_.rs` | `run_inner_loop`, `AggregatedUsage` | -//! | `tool_source.rs` | `SubagentToolSource` | +//! | `graph.rs` | `run_subagent_via_graph` — the sub-agent turn graph + tools | +//! | `usage.rs` | `AggregatedUsage` (cumulative usage stats) | //! | `handoff_helper.rs` | `apply_handoff` | -//! | `observer.rs` | `SubagentObserver` | //! | `checkpoint.rs` | `SubagentCheckpoint`, `parse_tool_arguments` | mod checkpoint; +mod graph; mod handoff_helper; -mod loop_; -mod observer; mod prompt; mod provider; mod runner; -mod tool_source; +mod usage; // Public entry point — the primary API surface consumed by the parent module. pub use runner::run_subagent; diff --git a/src/openhuman/agent/harness/subagent_runner/ops/observer.rs b/src/openhuman/agent/harness/subagent_runner/ops/observer.rs deleted file mode 100644 index 4fcfec396..000000000 --- a/src/openhuman/agent/harness/subagent_runner/ops/observer.rs +++ /dev/null @@ -1,236 +0,0 @@ -//! Sub-agent [`TurnObserver`] implementation. -//! -//! Accumulates usage stats, persists per-iteration transcripts, and -//! mirrors assistant intents / tool results / final responses to the -//! spawn's worker thread (when one is attached). - -use crate::openhuman::inference::provider::ChatMessage; -use crate::openhuman::memory_conversations::ConversationMessage; - -use super::super::super::session::transcript; -use super::loop_::AggregatedUsage; - -pub(super) struct SubagentObserver { - pub(super) worker_thread_id: Option, - pub(super) workspace_dir: std::path::PathBuf, - pub(super) transcript_stem: String, - pub(super) agent_id: String, - pub(super) task_id: String, - pub(super) force_text_mode: bool, - pub(super) usage: AggregatedUsage, - /// Provenance + usage of the sub-agent's most recent provider call. Persisted - /// onto the transcript's last assistant message (carries provider + model so - /// per-thread usage reads can price the sub-agent at its actual model). - pub(super) last_turn_usage: Option, -} - -impl SubagentObserver { - pub(super) fn append_worker_message( - &self, - content: String, - sender: String, - extra_metadata: serde_json::Value, - ) { - let Some(ref thread_id) = self.worker_thread_id else { - return; - }; - let message = ConversationMessage { - id: format!("{}:{}", sender, uuid::Uuid::new_v4()), - content, - message_type: "text".to_string(), - extra_metadata, - sender, - created_at: chrono::Utc::now().to_rfc3339(), - }; - if let Err(err) = crate::openhuman::memory_conversations::append_message( - self.workspace_dir.clone(), - thread_id, - message, - ) { - tracing::debug!( - agent_id = %self.agent_id, - thread_id = %thread_id, - error = %err, - "[subagent_runner] failed to append message to worker thread" - ); - } - } - - pub(super) fn persist_transcript(&self, history: &[ChatMessage]) { - let path = match transcript::resolve_keyed_transcript_path( - &self.workspace_dir, - &self.transcript_stem, - ) { - Ok(p) => p, - Err(err) => { - tracing::debug!( - agent_id = %self.agent_id, - error = %err, - "[subagent_runner] failed to resolve transcript path" - ); - return; - } - }; - let now = chrono::Utc::now().to_rfc3339(); - let meta = transcript::TranscriptMeta { - agent_name: self.agent_id.clone(), - agent_id: Some(self.agent_id.clone()), - agent_type: Some("subagent".to_string()), - dispatcher: "native".into(), - provider: self - .last_turn_usage - .as_ref() - .map(|usage| usage.provider.clone()), - model: self - .last_turn_usage - .as_ref() - .map(|usage| usage.model.clone()), - created: now.clone(), - updated: now, - turn_count: 1, - input_tokens: self.usage.input_tokens, - output_tokens: self.usage.output_tokens, - cached_input_tokens: self.usage.cached_input_tokens, - charged_amount_usd: self.usage.charged_amount_usd, - thread_id: crate::openhuman::inference::provider::thread_context::current_thread_id(), - task_id: Some(self.task_id.clone()), - }; - if let Err(err) = - transcript::write_transcript(&path, history, &meta, self.last_turn_usage.as_ref()) - { - tracing::debug!( - agent_id = %self.agent_id, - error = %err, - "[subagent_runner] failed to write transcript" - ); - } - } -} - -#[async_trait::async_trait] -impl super::super::super::engine::TurnObserver for SubagentObserver { - fn record_usage( - &mut self, - provider: &str, - model: &str, - usage: &crate::openhuman::inference::provider::UsageInfo, - ) { - self.usage.input_tokens += usage.input_tokens; - self.usage.output_tokens += usage.output_tokens; - self.usage.cached_input_tokens += usage.cached_input_tokens; - // Effective per-call cost: backend-charged when present, else the - // per-model catalog estimate (#4124) — so a sub-agent on a BYO/local - // provider that bills no charge still contributes a priced cost to the - // parent turn's net total. `charged_amount_usd` here is the *net cost*, - // matching the parent adapter's convention. - let call_cost = crate::openhuman::agent::cost::call_cost_usd(model, usage); - self.usage.charged_amount_usd += call_cost; - // Mirror the parent adapter: feed every sub-agent provider call into the - // global cost tracker so the cost dashboard/summary reflects delegated - // spend (previously sub-agent tokens were invisible to it). The per-turn - // rollup into the UI footer happens separately via `turn_subagent_usage`. - crate::openhuman::cost::record_provider_usage(model, usage); - // Provenance for the transcript's last assistant message (#4134): keeps - // the provider + model so per-thread usage reads can price the sub-agent - // at its actual model. - self.last_turn_usage = Some(transcript::TurnUsage { - provider: provider.to_string(), - model: model.to_string(), - usage: transcript::MessageUsage { - input: usage.input_tokens, - output: usage.output_tokens, - cached_input: usage.cached_input_tokens, - context_window: usage.context_window, - cost_usd: call_cost, - }, - ts: chrono::Utc::now().to_rfc3339(), - reasoning_content: None, - tool_calls: Vec::new(), - iteration: 0, - }); - } - - async fn on_assistant( - &mut self, - _display_text: &str, - response_text: &str, - reasoning_content: Option<&str>, - native_tool_calls: &[crate::openhuman::inference::provider::ToolCall], - parsed_calls: &[super::super::super::parse::ParsedToolCall], - iteration: usize, - is_final: bool, - ) { - let tool_calls = parsed_calls.len(); - if let Some(ref mut usage) = self.last_turn_usage { - usage.reasoning_content = reasoning_content - .map(str::trim) - .filter(|s| !s.is_empty()) - .map(ToString::to_string); - usage.tool_calls = native_tool_calls.to_vec(); - usage.iteration = (iteration + 1) as u32; - } - let extra = if is_final { - serde_json::json!({ - "scope": "worker_thread", - "agent_id": self.agent_id, - "task_id": self.task_id, - "iteration": iteration + 1, - "final": true, - }) - } else { - serde_json::json!({ - "scope": "worker_thread", - "agent_id": self.agent_id, - "task_id": self.task_id, - "iteration": iteration + 1, - "tool_calls": tool_calls, - }) - }; - self.append_worker_message(response_text.to_string(), "agent".to_string(), extra); - } - - fn on_tool_result( - &mut self, - call_id: &str, - tool_name: &str, - result_text: &str, - _success: bool, - iteration: usize, - ) { - // Native mode mirrors each tool result individually; text mode batches - // them in `on_results_batch` instead. - if self.force_text_mode { - return; - } - self.append_worker_message( - result_text.to_string(), - "user".to_string(), - serde_json::json!({ - "scope": "worker_thread", - "agent_id": self.agent_id, - "task_id": self.task_id, - "iteration": iteration + 1, - "tool_call_id": call_id, - "tool_name": tool_name, - }), - ); - } - - fn on_results_batch(&mut self, content: &str, iteration: usize) { - self.append_worker_message( - content.to_string(), - "user".to_string(), - serde_json::json!({ - "scope": "worker_thread", - "agent_id": self.agent_id, - "task_id": self.task_id, - "iteration": iteration + 1, - "mode": "text", - }), - ); - } - - fn after_iteration(&mut self, history: &[ChatMessage], _iteration: usize) { - self.persist_transcript(history); - } -} diff --git a/src/openhuman/agent/harness/subagent_runner/ops/runner.rs b/src/openhuman/agent/harness/subagent_runner/ops/runner.rs index cfdc39a83..0d1371116 100644 --- a/src/openhuman/agent/harness/subagent_runner/ops/runner.rs +++ b/src/openhuman/agent/harness/subagent_runner/ops/runner.rs @@ -33,9 +33,6 @@ use crate::openhuman::file_state::with_file_state_agent_id; use crate::openhuman::inference::provider::AGENT_TURN_MAX_OUTPUT_TOKENS; use crate::openhuman::tools::{Tool, ToolCategory, ToolSpec}; -use crate::openhuman::agent::harness::engine::TurnStop; - -use super::loop_::run_inner_loop; use super::prompt::{append_subagent_role_contract, dedup_tool_specs_by_name}; use super::provider::{ resolve_subagent_provider, user_is_signed_in_to_composio, LazyToolkitResolver, @@ -800,29 +797,92 @@ async fn run_typed_mode( model_vision, "[subagent_runner] resolved sub-agent model vision capability" ); - let (output, iterations, agg_usage, early_exit_tool, stop) = Box::pin(run_inner_loop( - subagent_provider.as_ref(), - &mut history, - &parent.all_tools, - dynamic_tools, - &filtered_specs, - allowed_names, - lazy_resolver, - &model, - model_vision, - temperature, - definition.effective_max_iterations(), - max_output_tokens, - task_id, - &definition.id, - options.worker_thread_id.clone(), - handoff_cache.as_deref(), - parent, - definition.iteration_policy == IterationPolicy::Extended, - definition.effective_tokenjuice_compression(), - options.run_queue.clone(), - )) - .await?; + // Sub-agent turns run through the tinyagents harness (issue #4249): the graph + // route reuses the same provider + tools and mirrors every legacy seam (child + // progress, steering, cap checkpoint, ask_user_clarification pause, + // worker-thread mirror). The legacy `run_inner_loop` has been removed. + // + // `model_vision` and `max_output_tokens` are now forwarded into the graph + // route (image rehydration + per-call output cap). `lazy_resolver` / + // `handoff_cache` — the integrations-agent progressive-disclosure seams — are + // not yet re-expressed on the tinyagents path; they need a tool-result + // interception middleware and are tracked as a follow-up (issue #4249, 1b). + let _ = (&lazy_resolver, &handoff_cache); + // Per-agent turn graph (issue #4249): `Default` runs the shared sub-agent + // graph; `Custom` hands the assembled turn to this agent's own graph runner + // (declared in its `graph.rs::graph()`). Every built-in agent selects + // `Default` today — the branch is the extension point. + use super::usage::AggregatedUsage; + use crate::openhuman::agent::harness::agent_graph::{ + AgentGraph, AgentTurnRequest, AgentTurnUsage, + }; + let (output, iterations, agg_usage, early_exit_tool, hit_cap) = match &definition.graph { + AgentGraph::Default => { + super::graph::run_subagent_via_graph( + subagent_provider.clone(), + &model, + temperature, + &mut history, + parent.all_tools.clone(), + dynamic_tools, + filtered_specs.clone(), + allowed_names, + definition.effective_max_iterations(), + options.run_queue.clone(), + parent.on_progress.clone(), + &definition.id, + task_id, + definition.iteration_policy == IterationPolicy::Extended, + options.worker_thread_id.clone(), + parent.workspace_dir.clone(), + max_output_tokens, + model_vision, + ) + .await? + } + AgentGraph::Custom(run) => { + let req = AgentTurnRequest { + provider: subagent_provider.clone(), + model: model.clone(), + temperature, + history: std::mem::take(&mut history), + parent_tools: parent.all_tools.clone(), + dynamic_tools, + specs: filtered_specs.clone(), + allowed_names, + max_iterations: definition.effective_max_iterations(), + run_queue: options.run_queue.clone(), + on_progress: parent.on_progress.clone(), + agent_id: definition.id.clone(), + task_id: task_id.to_string(), + extended_policy: definition.iteration_policy == IterationPolicy::Extended, + worker_thread_id: options.worker_thread_id.clone(), + workspace_dir: parent.workspace_dir.clone(), + max_output_tokens, + model_vision, + }; + let res = run(req).await?; + history = res.history; + let AgentTurnUsage { + input_tokens, + output_tokens, + cached_input_tokens, + charged_amount_usd, + } = res.usage; + ( + res.output, + res.iterations, + AggregatedUsage { + input_tokens, + output_tokens, + cached_input_tokens, + charged_amount_usd, + }, + res.early_exit_tool, + res.hit_cap, + ) + } + }; // Determine status: if the turn engine exited early because of // ask_user_clarification, checkpoint the history and return @@ -888,27 +948,20 @@ async fn run_typed_mode( question, options: options_vec, } - } else { - use crate::openhuman::agent::harness::subagent_runner::types::SubagentRunStatus; - match stop { - // A circuit-breaker halt (stuck) or the iteration cap means the - // sub-agent stopped WITHOUT reaching its goal. Surface it as - // Incomplete so the delegating agent relays the partial result + - // blocker instead of treating the summary as a finished answer or - // re-spinning the identical delegation (#4096). - TurnStop::Halted => SubagentRunStatus::Incomplete { - reason: - "got stuck and stopped making progress (a no-progress circuit breaker tripped)" - .into(), - }, - TurnStop::Cap => SubagentRunStatus::Incomplete { - reason: "reached its tool-call limit before finishing".into(), - }, - // A clean final response. (An `ask_user_clarification` early-exit is - // already handled by the branch above, so EarlyExit here — which - // sub-agents only reach via that tool — folds into Completed.) - TurnStop::Final | TurnStop::EarlyExit => SubagentRunStatus::Completed, + } else if hit_cap { + // The tinyagents run stopped at the model-call cap with work still + // pending (graph summarized a resumable checkpoint into `output`). + // Surface it as Incomplete so the delegating agent relays the partial + // result + blocker instead of treating the summary as a finished answer + // or re-spinning the identical delegation (#4096). + crate::openhuman::agent::harness::subagent_runner::types::SubagentRunStatus::Incomplete { + reason: "reached its tool-call limit before finishing".into(), } + } else { + // A clean final response. (An `ask_user_clarification` early-exit is + // handled by the branch above.) The legacy circuit-breaker `Halted` + // distinction folds into the tinyagents stop-hook / cap handling. + crate::openhuman::agent::harness::subagent_runner::types::SubagentRunStatus::Completed }; // Surface this run's token/cost totals so the parent turn can roll them diff --git a/src/openhuman/agent/harness/subagent_runner/ops/tool_source.rs b/src/openhuman/agent/harness/subagent_runner/ops/tool_source.rs deleted file mode 100644 index cce0fc554..000000000 --- a/src/openhuman/agent/harness/subagent_runner/ops/tool_source.rs +++ /dev/null @@ -1,135 +0,0 @@ -//! Sub-agent [`ToolSource`] implementation. -//! -//! Looks up tools in `extra_tools` then the parent registry, lazily registers -//! toolkit actions the fuzzy filter omitted, rejects names outside the -//! allowlist, and routes execution through the shared [`run_one_tool`] (so -//! sub-agents now get the same approval gate, audit, credential scrub, -//! tokenjuice and timeout as the channel loop), then applies the -//! progressive-disclosure handoff. - -use std::collections::HashSet; - -use crate::openhuman::tools::{Tool, ToolSpec}; - -use super::handoff_helper::apply_handoff; -use super::provider::LazyToolkitResolver; -use crate::openhuman::agent::harness::subagent_runner::handoff::ResultHandoffCache; - -/// Sub-agent [`ToolSource`]: looks up tools in `extra_tools` then the parent -/// registry, lazily registers toolkit actions the fuzzy filter omitted, rejects -/// names outside the allowlist, and routes execution through the shared -/// [`run_one_tool`] (so sub-agents now get the same approval gate, audit, -/// credential scrub, tokenjuice and timeout as the channel loop), then applies -/// the progressive-disclosure handoff. -pub(super) struct SubagentToolSource<'a> { - pub(super) parent_tools: &'a [Box], - pub(super) extra_tools: Vec>, - pub(super) allowed_names: HashSet, - pub(super) lazy_resolver: Option, - pub(super) advertised_specs: Vec, - pub(super) handoff_cache: Option<&'a ResultHandoffCache>, - pub(super) policy: crate::openhuman::tools::policy::DefaultToolPolicy, - pub(super) agent_id: String, - pub(super) tokenjuice_compression: crate::openhuman::tokenjuice::AgentTokenjuiceCompression, -} - -#[async_trait::async_trait] -impl super::super::super::engine::ToolSource for SubagentToolSource<'_> { - fn request_specs(&self) -> &[ToolSpec] { - &self.advertised_specs - } - - async fn execute_call( - &mut self, - call: &super::super::super::parse::ParsedToolCall, - iteration: usize, - progress: &dyn super::super::super::engine::ProgressReporter, - progress_call_id: &str, - ) -> super::super::super::engine::ToolRunResult { - // Lazy registration: a call for an unknown tool that matches a real - // action slug in the bound toolkit gets built on the spot and admitted - // to the allowlist. The fuzzy top-K filter keeps schemas out of the - // prompt, not out of execution. - if !self.allowed_names.contains(&call.name) { - if let Some(resolver) = self.lazy_resolver.as_ref() { - if let Some(tool) = resolver.resolve(&call.name) { - tracing::info!( - agent_id = %self.agent_id, - tool = %call.name, - "[subagent_runner] lazily registered toolkit action outside fuzzy top-K" - ); - self.allowed_names.insert(tool.name().to_string()); - self.extra_tools.push(tool); - } - } - } - - if !self.allowed_names.contains(&call.name) { - tracing::warn!( - agent_id = %self.agent_id, - tool = %call.name, - "[subagent_runner] tool not in allowlist for this sub-agent" - ); - let iteration_u32 = (iteration + 1) as u32; - // Not-in-allowlist reject path: no resolved tool to label, so defer - // to the client formatter. (Allowed sub-agent calls run through the - // shared `run_one_tool`, which computes the label there.) - progress - .tool_started( - progress_call_id, - &call.name, - &call.arguments, - iteration_u32, - None, - None, - ) - .await; - let mut available: Vec<&str> = self.allowed_names.iter().map(|s| s.as_str()).collect(); - if let Some(resolver) = self.lazy_resolver.as_ref() { - available.extend(resolver.known_slugs()); - } - available.sort_unstable(); - available.dedup(); - let text = format!( - "Error: tool '{}' is not available to the {} sub-agent. Available tools: {}", - call.name, - self.agent_id, - available.join(", ") - ); - progress - .tool_completed(progress_call_id, &call.name, false, &text, 0, iteration_u32) - .await; - return super::super::super::engine::ToolRunResult { - text, - success: false, - }; - } - - let tool_opt: Option<&dyn Tool> = self - .extra_tools - .iter() - .find(|t| t.name() == call.name) - .or_else(|| self.parent_tools.iter().find(|t| t.name() == call.name)) - .map(|b| b.as_ref()); - let outcome = super::super::super::engine::run_one_tool( - tool_opt, - call, - iteration, - progress, - &self.policy, - None, - progress_call_id, - self.tokenjuice_compression, - ) - .await; - - let text = match self.handoff_cache { - Some(cache) => apply_handoff(cache, &call.name, "", &self.agent_id, outcome.text), - None => outcome.text, - }; - super::super::super::engine::ToolRunResult { - text, - success: outcome.success, - } - } -} diff --git a/src/openhuman/agent/harness/subagent_runner/ops/usage.rs b/src/openhuman/agent/harness/subagent_runner/ops/usage.rs new file mode 100644 index 000000000..07011f49d --- /dev/null +++ b/src/openhuman/agent/harness/subagent_runner/ops/usage.rs @@ -0,0 +1,14 @@ +//! Cumulative sub-agent usage stats. +//! +//! The sub-agent inner tool-call loop that produced these was retired in favour +//! of the tinyagents harness (issue #4249); only the usage aggregate it returned +//! survives, reused by the tinyagents sub-agent route ([`super::graph`]). + +/// Cumulative usage stats gathered across a sub-agent run. +#[derive(Debug, Clone, Default)] +pub(super) struct AggregatedUsage { + pub(super) input_tokens: u64, + pub(super) output_tokens: u64, + pub(super) cached_input_tokens: u64, + pub(super) charged_amount_usd: f64, +} diff --git a/src/openhuman/agent/harness/subagent_runner/ops_tests.rs b/src/openhuman/agent/harness/subagent_runner/ops_tests.rs index 194a555a5..4f7b18a53 100644 --- a/src/openhuman/agent/harness/subagent_runner/ops_tests.rs +++ b/src/openhuman/agent/harness/subagent_runner/ops_tests.rs @@ -70,6 +70,7 @@ fn make_def_named_tools(names: &[&str]) -> AgentDefinition { delegate_name: None, agent_tier: crate::openhuman::agent::harness::definition::AgentTier::Worker, source: crate::openhuman::agent::harness::definition::DefinitionSource::Builtin, + graph: Default::default(), } } @@ -515,37 +516,43 @@ async fn typed_mode_returns_text_through_runner() { } #[tokio::test] -async fn stuck_subagent_returns_incomplete_status() { +async fn capped_no_progress_subagent_returns_incomplete_status() { use crate::openhuman::agent::harness::subagent_runner::SubagentRunStatus; - // A sub-agent that re-issues the identical (succeeding) tool call makes no - // progress. The repeat-call breaker halts it, and the runner reports - // `Incomplete` (NOT `Completed`) so the orchestrator gets a structured + // A sub-agent that keeps issuing tool calls without ever producing a final + // answer makes no progress and is halted at its model-call cap. The runner + // summarizes the run-so-far into a resumable checkpoint and reports + // `Incomplete` (NOT `Completed`) so the orchestrator relays the partial // handback instead of mistaking the no-progress summary for a result (#4096). + // + // The legacy repeat-identical-call circuit-breaker `Halted` distinction + // folded into this cap handling during the tinyagents migration (#4249) — + // see `run_subagent`'s status mapping. With `max_iterations = 2` the two + // scripted tool calls exhaust the budget; the checkpoint summary call then + // draws the deterministic "reached my tool-call limit" digest. let provider = ScriptedProvider::new(vec![ tool_response("file_read", "{\"path\":\"a\"}"), tool_response("file_read", "{\"path\":\"a\"}"), - tool_response("file_read", "{\"path\":\"a\"}"), - tool_response("file_read", "{\"path\":\"a\"}"), ]); let parent = make_parent(provider.clone(), vec![stub("file_read")]); - let def = make_def_named_tools(&["file_read"]); + let mut def = make_def_named_tools(&["file_read"]); + def.max_iterations = 2; let outcome = with_parent_context(parent, async { run_subagent(&def, "read the file", SubagentRunOptions::default()).await }) .await - .expect("a stuck halt is still Ok (not Err)"); + .expect("a cap halt is still Ok (not Err)"); match outcome.status { SubagentRunStatus::Incomplete { reason } => assert!( - reason.contains("stuck") || reason.contains("progress"), - "incomplete reason should describe the stuck stop: {reason}" + reason.contains("limit") || reason.contains("tool-call"), + "incomplete reason should describe the cap stop: {reason}" ), other => panic!("expected Incomplete, got {other:?}"), } assert!( - outcome.output.contains("same tool call"), - "the partial output should carry the repeat-call no-progress summary: {}", + outcome.output.contains("tool-call limit"), + "the partial output should carry the cap-hit checkpoint summary: {}", outcome.output ); } diff --git a/src/openhuman/agent/harness/subagent_runner/types.rs b/src/openhuman/agent/harness/subagent_runner/types.rs index 3ffdf4635..b0cc14b20 100644 --- a/src/openhuman/agent/harness/subagent_runner/types.rs +++ b/src/openhuman/agent/harness/subagent_runner/types.rs @@ -69,10 +69,10 @@ pub struct SubagentRunOptions { pub worktree_action_dir: Option, /// Steering channel for a running (typically async) sub-agent. When set, - /// the inner `run_turn_engine` drains steer/collect messages from this - /// queue at iteration boundaries — exactly like the main agent loop — so - /// the parent can `steer_subagent` mid-flight. `None` keeps today's - /// non-steerable behaviour. + /// the tinyagents harness drains steer/collect messages from this queue at + /// iteration boundaries — exactly like the main agent loop — so the parent + /// can `steer_subagent` mid-flight. `None` keeps today's non-steerable + /// behaviour. pub run_queue: Option>, } diff --git a/src/openhuman/agent/harness/test_support.rs b/src/openhuman/agent/harness/test_support.rs deleted file mode 100644 index f40641af6..000000000 --- a/src/openhuman/agent/harness/test_support.rs +++ /dev/null @@ -1,755 +0,0 @@ -//! Smart-mock test support for the agent harness. -//! -//! This module provides a reusable "fake LLM" that drives the real -//! [`run_tool_call_loop`] without needing any network access. Two -//! building blocks are exposed: -//! -//! 1. [`KeywordScriptedProvider`] — a [`Provider`] implementation that -//! inspects the latest `user` message of the conversation and emits -//! canned tool calls (or a final reply) when a configured keyword -//! matches. The first turn that has *no* matching rule returns a -//! plain "done" reply, which terminates the loop deterministically. -//! -//! Compared to the hand-rolled `ScriptedProvider` in -//! [`super::tool_loop_tests`], this provider: -//! * Reacts to the rolling conversation state instead of replaying -//! a fixed queue — so tests can exercise iterative loops where -//! what the LLM does next depends on what tools returned. -//! * Supports both **native** OpenAI-style `tool_calls` and -//! **prompt-guided** `` text — flipping -//! a single flag toggles which surface the harness exercises. -//! * Records the messages it saw and the responses it returned for -//! post-hoc assertion. -//! -//! 2. [`spawn_fake_composio_backend`] — boots a minimal axum app that -//! responds to the `/agent-integrations/composio/*` routes with -//! realistic-looking fixture data (real-world toolkit/action shapes, -//! not synthetic gibberish). Tests can pair this with a -//! [`crate::openhuman::composio::ComposioClient`] to exercise the -//! full agent → tool → backend → response flow against a hermetic -//! in-process server. -//! -//! Both helpers are `#[cfg(test)]`-only so they never leak into release -//! binaries. - -use std::collections::VecDeque; -use std::sync::Arc; - -use async_trait::async_trait; -use parking_lot::Mutex; -use serde_json::json; - -use crate::openhuman::inference::provider::traits::ProviderCapabilities; -use crate::openhuman::inference::provider::{ - ChatMessage, ChatRequest, ChatResponse, Provider, ToolCall, -}; - -/// One scripted reaction the [`KeywordScriptedProvider`] can emit when -/// it sees its keyword in the latest user/tool turn. -#[derive(Debug, Clone)] -pub struct KeywordRule { - /// Substring matched (case-insensitive) against the latest user - /// or tool message in the conversation. - pub keyword: String, - /// Tool calls to emit. Empty ⇒ no tool calls, only `final_text`. - pub tool_calls: Vec, - /// Optional plain-text body to include alongside any tool calls. - /// When `tool_calls` is empty, this becomes the loop-terminating - /// final response. - pub final_text: Option, - /// How many times this rule may fire. `None` ⇒ unlimited. - pub max_fires: Option, -} - -#[derive(Debug, Clone)] -pub struct ScriptedToolCall { - pub name: String, - pub arguments: serde_json::Value, -} - -impl ScriptedToolCall { - pub fn new(name: impl Into, arguments: serde_json::Value) -> Self { - Self { - name: name.into(), - arguments, - } - } -} - -impl KeywordRule { - pub fn final_reply(keyword: impl Into, text: impl Into) -> Self { - Self { - keyword: keyword.into(), - tool_calls: Vec::new(), - final_text: Some(text.into()), - max_fires: None, - } - } - - pub fn tool_call(keyword: impl Into, call: ScriptedToolCall) -> Self { - Self { - keyword: keyword.into(), - tool_calls: vec![call], - final_text: None, - max_fires: Some(1), - } - } - - pub fn with_text(mut self, text: impl Into) -> Self { - self.final_text = Some(text.into()); - self - } - - pub fn unlimited(mut self) -> Self { - self.max_fires = None; - self - } -} - -/// Snapshot of one turn the provider served — handy for tests that -/// want to assert what the LLM "saw" without coupling to the harness -/// internals. -#[derive(Debug, Clone)] -pub struct ProviderTurn { - pub messages: Vec, - pub rule_keyword: Option, - pub emitted_tool_calls: Vec, - pub emitted_text: Option, -} - -struct ProviderState { - rules: Vec, - fired: Vec, - turns: Vec, - fallback_text: String, - next_call_id: usize, - /// Optional queue of scripted responses to consume *before* the - /// keyword rules run — useful when a test wants the first turn to - /// behave deterministically regardless of the user message. - forced: VecDeque, -} - -/// Smart provider that reacts to conversation state via keyword rules. -pub struct KeywordScriptedProvider { - state: Arc>, - native_tools: bool, - vision: bool, -} - -impl KeywordScriptedProvider { - pub fn new(rules: Vec) -> Self { - Self { - state: Arc::new(Mutex::new(ProviderState { - rules, - fired: Vec::new(), - turns: Vec::new(), - fallback_text: "done".to_string(), - next_call_id: 0, - forced: VecDeque::new(), - })), - native_tools: false, - vision: false, - } - } - - pub fn with_native_tools(mut self, enabled: bool) -> Self { - self.native_tools = enabled; - self - } - - pub fn with_vision(mut self, enabled: bool) -> Self { - self.vision = enabled; - self - } - - pub fn with_fallback(self, text: impl Into) -> Self { - { - let mut guard = self.state.lock(); - guard.fallback_text = text.into(); - } - self - } - - pub fn push_forced_response(&self, resp: ChatResponse) { - self.state.lock().forced.push_back(resp); - } - - pub fn turns(&self) -> Vec { - self.state.lock().turns.clone() - } - - pub fn turn_count(&self) -> usize { - self.state.lock().turns.len() - } -} - -fn latest_user_or_tool_msg(messages: &[ChatMessage]) -> Option<&ChatMessage> { - messages - .iter() - .rev() - .find(|m| m.role == "user" || m.role == "tool") -} - -#[async_trait] -impl Provider for KeywordScriptedProvider { - fn capabilities(&self) -> ProviderCapabilities { - ProviderCapabilities { - native_tool_calling: self.native_tools, - vision: self.vision, - } - } - - async fn chat_with_system( - &self, - _system_prompt: Option<&str>, - _message: &str, - _model: &str, - _temperature: f64, - ) -> anyhow::Result { - Ok("fallback".into()) - } - - async fn chat( - &self, - request: ChatRequest<'_>, - _model: &str, - _temperature: f64, - ) -> anyhow::Result { - let messages = request.messages.to_vec(); - let mut state = self.state.lock(); - - // Forced queue wins, regardless of keyword matching. - if let Some(resp) = state.forced.pop_front() { - state.turns.push(ProviderTurn { - messages, - rule_keyword: None, - emitted_tool_calls: resp.tool_calls.clone(), - emitted_text: resp.text.clone(), - }); - return Ok(resp); - } - - let probe = latest_user_or_tool_msg(&messages) - .map(|m| m.content.to_lowercase()) - .unwrap_or_default(); - - let mut chosen: Option = None; - for (idx, rule) in state.rules.iter().enumerate() { - let fired = *state.fired.get(idx).unwrap_or(&0); - if let Some(cap) = rule.max_fires { - if fired >= cap { - continue; - } - } - if probe.contains(&rule.keyword.to_lowercase()) { - chosen = Some(idx); - break; - } - } - - let (rule_keyword, tool_calls, text) = if let Some(idx) = chosen { - while state.fired.len() <= idx { - state.fired.push(0); - } - state.fired[idx] += 1; - let rule = state.rules[idx].clone(); - let tool_calls: Vec = if self.native_tools { - rule.tool_calls - .iter() - .map(|c| { - let id = state.next_call_id; - state.next_call_id += 1; - ToolCall { - id: format!("call_{id}"), - name: c.name.clone(), - arguments: c.arguments.to_string(), - extra_content: None, - } - }) - .collect() - } else { - Vec::new() - }; - - let text = if self.native_tools { - rule.final_text.clone() - } else if !rule.tool_calls.is_empty() { - // Prompt-guided: emit XML-wrapped tool calls in text. - let mut body = String::new(); - if let Some(prefix) = &rule.final_text { - body.push_str(prefix); - if !prefix.ends_with('\n') { - body.push('\n'); - } - } - for c in &rule.tool_calls { - body.push_str(""); - body.push_str(&json!({"name": c.name, "arguments": c.arguments}).to_string()); - body.push_str("\n"); - } - Some(body) - } else { - rule.final_text.clone() - }; - - (Some(rule.keyword.clone()), tool_calls, text) - } else { - // No rule matched — emit the fallback as the final reply - // so the loop terminates rather than hanging. - (None, Vec::new(), Some(state.fallback_text.clone())) - }; - - let resp = ChatResponse { - text: text.clone(), - tool_calls: tool_calls.clone(), - usage: None, - reasoning_content: None, - }; - - state.turns.push(ProviderTurn { - messages, - rule_keyword, - emitted_tool_calls: tool_calls, - emitted_text: text, - }); - - Ok(resp) - } -} - -// ── Fake Composio backend ────────────────────────────────────────── - -use crate::openhuman::composio::ComposioClient; -use crate::openhuman::integrations::IntegrationClient; -use axum::{ - extract::{Path, State}, - routing::{delete, get, post}, - Json, Router, -}; - -/// Realistic-looking Composio fixture data (toolkit slugs, action -/// names, and parameter shapes lifted from the production catalog — -/// just enough to exercise the schemas without coupling tests to the -/// upstream API). -#[derive(Default, Clone)] -pub struct ComposioFixture { - pub toolkits: Vec, - pub connections: Vec, - pub tools: Vec, - /// Per-action canned execute responses, keyed by action slug. - pub execute_responses: std::collections::HashMap, - /// Ordered request-aware execute overrides. The first matching rule wins. - pub execute_rules: Vec, -} - -#[derive(Debug, Clone)] -pub struct ComposioExecuteRule { - pub action: String, - pub argument_path: Option, - pub argument_contains: Option, - pub response: serde_json::Value, -} - -impl ComposioExecuteRule { - pub fn new(action: impl Into, response: serde_json::Value) -> Self { - Self { - action: action.into(), - argument_path: None, - argument_contains: None, - response, - } - } - - pub fn when_argument_contains( - mut self, - path: impl Into, - needle: impl Into, - ) -> Self { - self.argument_path = Some(path.into()); - self.argument_contains = Some(needle.into()); - self - } -} - -impl ComposioFixture { - /// A reasonable default fixture: GMail/Notion/GitHub connections - /// with two actions each. Use this when a test just needs *some* - /// Composio data and doesn't care about the exact shape. - pub fn realistic() -> Self { - Self { - toolkits: vec![ - "gmail".to_string(), - "notion".to_string(), - "github".to_string(), - "slack".to_string(), - ], - connections: vec![ - json!({ - "id": "conn_gmail_1", - "toolkit": "gmail", - "status": "ACTIVE", - "createdAt": "2026-04-01T12:00:00Z", - }), - json!({ - "id": "conn_notion_1", - "toolkit": "notion", - "status": "ACTIVE", - "createdAt": "2026-04-02T08:00:00Z", - }), - json!({ - "id": "conn_github_1", - "toolkit": "github", - "status": "ACTIVE", - "createdAt": "2026-04-03T15:30:00Z", - }), - ], - tools: vec![ - json!({ - "name": "GMAIL_SEND_EMAIL", - "description": "Send an email via Gmail", - "parameters": { - "type": "object", - "properties": { - "recipient_email": {"type": "string"}, - "subject": {"type": "string"}, - "body": {"type": "string"}, - }, - "required": ["recipient_email", "subject", "body"], - }, - }), - json!({ - "name": "GMAIL_FETCH_EMAILS", - "description": "Fetch emails from Gmail", - "parameters": { - "type": "object", - "properties": { - "query": {"type": "string"}, - "max_results": {"type": "integer"}, - }, - }, - }), - json!({ - "name": "NOTION_CREATE_PAGE", - "description": "Create a new Notion page", - "parameters": { - "type": "object", - "properties": { - "parent_id": {"type": "string"}, - "title": {"type": "string"}, - }, - "required": ["parent_id", "title"], - }, - }), - json!({ - "name": "GITHUB_CREATE_ISSUE", - "description": "Open a GitHub issue", - "parameters": { - "type": "object", - "properties": { - "owner": {"type": "string"}, - "repo": {"type": "string"}, - "title": {"type": "string"}, - "body": {"type": "string"}, - }, - "required": ["owner", "repo", "title"], - }, - }), - ], - execute_responses: [ - ( - "GMAIL_SEND_EMAIL".to_string(), - json!({"message_id": "gmail-msg-1234", "thread_id": "gmail-thread-9999"}), - ), - ( - "GMAIL_FETCH_EMAILS".to_string(), - json!({ - "messages": [ - {"id": "m1", "subject": "Welcome", "from": "team@openhuman.com"}, - {"id": "m2", "subject": "Invoice", "from": "billing@stripe.com"}, - ] - }), - ), - ( - "NOTION_CREATE_PAGE".to_string(), - json!({"page_id": "notion-page-abc123", "url": "https://notion.so/abc123"}), - ), - ( - "GITHUB_CREATE_ISSUE".to_string(), - json!({ - "number": 42, - "html_url": "https://github.com/example/repo/issues/42", - }), - ), - ] - .into_iter() - .collect(), - execute_rules: Vec::new(), - } - } -} - -fn json_path<'a>(value: &'a serde_json::Value, path: &str) -> Option<&'a serde_json::Value> { - path.split('.') - .filter(|segment| !segment.is_empty()) - .try_fold(value, |current, segment| current.get(segment)) -} - -fn match_execute_rule( - rules: &[ComposioExecuteRule], - action: &str, - body: &serde_json::Value, -) -> Option { - rules.iter().find_map(|rule| { - if rule.action != action { - return None; - } - if let Some(path) = rule.argument_path.as_deref() { - let actual = json_path(body, path)?; - if let Some(needle) = rule.argument_contains.as_deref() { - if !actual - .to_string() - .to_ascii_lowercase() - .contains(&needle.to_ascii_lowercase()) - { - return None; - } - } - } - Some(rule.response.clone()) - }) -} - -#[derive(Clone)] -struct FakeComposioState { - fixture: Arc>, - requests: Arc>>, -} - -/// Handle to a spawned in-process Composio backend. -pub struct FakeComposioBackend { - pub base_url: String, - state: FakeComposioState, -} - -impl FakeComposioBackend { - /// All `(method, path, body)` requests received in order. - pub fn requests(&self) -> Vec<(String, String, serde_json::Value)> { - self.state.requests.lock().clone() - } - - /// Build a `ComposioClient` pointed at this backend. - pub fn client(&self) -> ComposioClient { - let inner = Arc::new(IntegrationClient::new( - self.base_url.clone(), - "test-token".into(), - )); - ComposioClient::new(inner) - } - - /// Build an `Arc` that — when passed through the mode-aware - /// factory (`create_composio_client`) — resolves to a backend - /// `ComposioClient` pointing at this fake backend, **and persist it** - /// to `config_path` on disk, returning the workspace dir to point - /// `OPENHUMAN_WORKSPACE` at. - /// - /// Post-#1710-Wave-4, factory-routed tools - /// ([`crate::openhuman::composio::ComposioActionTool`], - /// `ComposioExecuteTool`, `ProviderContext`) reload config via - /// `config_rpc::load_config_with_timeout()` per call rather than - /// using the injected `Arc` — so the injected config only - /// influences routing if it is the live on-disk config the loader - /// resolves. Callers must hold `crate::openhuman::config::TEST_ENV_LOCK` - /// and `std::env::set_var("OPENHUMAN_WORKSPACE", &workspace_root)` - /// (the returned path's parent) so the loader reads this config. - /// - /// Returns `(Arc, workspace_root)` where `workspace_root` is - /// the tempdir the config + auth-profile live in (the value to set - /// `OPENHUMAN_WORKSPACE` to). The tempdir is leaked so it stays - /// valid for the test's lifetime. - pub async fn config_persisted( - &self, - ) -> ( - std::sync::Arc, - std::path::PathBuf, - ) { - use crate::openhuman::credentials::{ - AuthService, APP_SESSION_PROVIDER, DEFAULT_AUTH_PROFILE_NAME, - }; - - let tmp = tempfile::tempdir().expect("tempdir for FakeComposioBackend::config_persisted"); - let workspace_root = tmp.path().to_path_buf(); - let mut config = crate::openhuman::config::Config::default(); - config.workspace_dir = workspace_root.join("workspace"); - config.config_path = workspace_root.join("config.toml"); - config.api_url = Some(self.base_url.clone()); - config.composio.mode = crate::openhuman::config::schema::COMPOSIO_MODE_BACKEND.to_string(); - config.secrets.encrypt = false; - let auth = AuthService::from_config(&config); - auth.store_provider_token( - APP_SESSION_PROVIDER, - DEFAULT_AUTH_PROFILE_NAME, - "test-token", - std::collections::HashMap::new(), - true, - ) - .expect("store fake app-session token for FakeComposioBackend::config_persisted"); - // Persist so `load_config_with_timeout()` (resolving the workspace - // from `OPENHUMAN_WORKSPACE`) reads exactly this config. - config - .save() - .await - .expect("persist FakeComposioBackend config to disk"); - std::mem::forget(tmp); - (std::sync::Arc::new(config), workspace_root) - } -} - -async fn record( - requests: &Arc>>, - method: &str, - path: &str, - body: Option<&B>, -) { - let body_v = match body { - Some(b) => serde_json::to_value(b).unwrap_or(serde_json::Value::Null), - None => serde_json::Value::Null, - }; - requests - .lock() - .push((method.to_string(), path.to_string(), body_v)); -} - -/// Spawn an in-process Composio backend on `127.0.0.1:0`. -pub async fn spawn_fake_composio_backend(fixture: ComposioFixture) -> FakeComposioBackend { - let state = FakeComposioState { - fixture: Arc::new(Mutex::new(fixture)), - requests: Arc::new(Mutex::new(Vec::new())), - }; - - let app = Router::new() - .route( - "/agent-integrations/composio/toolkits", - get({ - let st = state.clone(); - move || async move { - record::<()>(&st.requests, "GET", "/toolkits", None).await; - let toolkits = st.fixture.lock().toolkits.clone(); - Json(json!({ - "success": true, - "data": { "toolkits": toolkits } - })) - } - }), - ) - .route( - "/agent-integrations/composio/connections", - get({ - let st = state.clone(); - move || async move { - record::<()>(&st.requests, "GET", "/connections", None).await; - let connections = st.fixture.lock().connections.clone(); - Json(json!({ - "success": true, - "data": { "connections": connections } - })) - } - }), - ) - .route( - "/agent-integrations/composio/tools", - get({ - let st = state.clone(); - move || async move { - record::<()>(&st.requests, "GET", "/tools", None).await; - let tools = st.fixture.lock().tools.clone(); - Json(json!({ - "success": true, - "data": { "tools": tools } - })) - } - }), - ) - .route( - "/agent-integrations/composio/authorize", - post({ - let st = state.clone(); - move |Json(body): Json| async move { - record(&st.requests, "POST", "/authorize", Some(&body)).await; - let toolkit = body - .get("toolkit") - .and_then(|v| v.as_str()) - .unwrap_or("unknown"); - Json(json!({ - "success": true, - "data": { - "connectUrl": format!("https://composio.dev/auth/{toolkit}"), - "connectionId": format!("conn_{toolkit}_pending"), - } - })) - } - }), - ) - .route( - "/agent-integrations/composio/execute", - post({ - let st = state.clone(); - move |Json(body): Json| async move { - record(&st.requests, "POST", "/execute", Some(&body)).await; - // ComposioClient::execute_tool sends `{tool, arguments}`. - let action = body - .get("tool") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); - let fx = st.fixture.lock(); - let response = match_execute_rule(&fx.execute_rules, &action, &body) - .or_else(|| fx.execute_responses.get(&action).cloned()) - .unwrap_or_else(|| json!({"ok": true, "action": action.clone()})); - // Wrap in the BackendResponse envelope expected by - // IntegrationClient, with the inner shape matching - // ComposioExecuteResponse. - Json(json!({ - "success": true, - "data": { - "data": response, - "successful": true, - "costUsd": 0.0, - } - })) - } - }), - ) - .route( - "/agent-integrations/composio/connections/{id}", - delete({ - let st = state.clone(); - move |Path(id): Path| async move { - record::<()>(&st.requests, "DELETE", &format!("/connections/{id}"), None).await; - let mut fx = st.fixture.lock(); - fx.connections - .retain(|c| c.get("id").and_then(|v| v.as_str()).unwrap_or("") != id); - Json(json!({ - "success": true, - "data": {"deleted": true} - })) - } - }), - ) - .with_state(state.clone()); - - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - tokio::spawn(async move { - let _ = axum::serve(listener, app).await; - }); - - FakeComposioBackend { - base_url: format!("http://127.0.0.1:{}", addr.port()), - state, - } -} - -// A handler signature placeholder to silence unused warnings when the -// State extractor isn't reached (e.g. when only sub-routes fire). -#[allow(dead_code)] -async fn _unused(_state: State) {} diff --git a/src/openhuman/agent/harness/test_support_tests.rs b/src/openhuman/agent/harness/test_support_tests.rs deleted file mode 100644 index 04a2278e0..000000000 --- a/src/openhuman/agent/harness/test_support_tests.rs +++ /dev/null @@ -1,1757 +0,0 @@ -//! Behavioural tests of the agent harness driven by the smart mock -//! provider in [`super::test_support`]. These exercise the real -//! [`run_tool_call_loop`] path end-to-end — no provider stubbing inside -//! the test bodies — and surface regressions in tool dispatch, parsing, -//! and history threading. - -use super::test_support::{ - spawn_fake_composio_backend, ComposioExecuteRule, ComposioFixture, KeywordRule, - KeywordScriptedProvider, ScriptedToolCall, -}; -use super::tool_loop::run_tool_call_loop; -use crate::openhuman::inference::provider::{ChatMessage, ChatRequest, ChatResponse, Provider}; -use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolCategory, ToolResult, ToolScope}; -use async_trait::async_trait; -use serde_json::json; -use std::sync::{ - atomic::{AtomicUsize, Ordering}, - Arc, -}; - -fn mm() -> crate::openhuman::config::MultimodalConfig { - crate::openhuman::config::MultimodalConfig::default() -} - -fn mff() -> crate::openhuman::config::MultimodalFileConfig { - crate::openhuman::config::MultimodalFileConfig::default() -} - -#[tokio::test] -async fn keyword_provider_records_forced_then_fallback_turns() { - let provider = - KeywordScriptedProvider::new(vec![KeywordRule::final_reply("matched", "final answer")]) - .with_native_tools(true) - .with_vision(true) - .with_fallback("fallback reply"); - - let caps = provider.capabilities(); - assert!(caps.native_tool_calling); - assert!(caps.vision); - - provider.push_forced_response(ChatResponse { - text: Some("forced reply".into()), - tool_calls: vec![], - usage: None, - reasoning_content: None, - }); - - let messages = vec![ChatMessage::user("nothing should match here")]; - let forced = provider - .chat( - ChatRequest { - messages: &messages, - tools: None, - stream: None, - max_tokens: None, - }, - "test-model", - 0.0, - ) - .await - .expect("forced response"); - assert_eq!(forced.text.as_deref(), Some("forced reply")); - - let fallback = provider - .chat( - ChatRequest { - messages: &messages, - tools: None, - stream: None, - max_tokens: None, - }, - "test-model", - 0.0, - ) - .await - .expect("fallback response"); - assert_eq!(fallback.text.as_deref(), Some("fallback reply")); - - let turns = provider.turns(); - assert_eq!(turns.len(), 2); - assert_eq!(turns[0].rule_keyword, None); - assert_eq!(turns[0].emitted_text.as_deref(), Some("forced reply")); - assert_eq!(turns[1].rule_keyword, None); - assert_eq!(turns[1].emitted_text.as_deref(), Some("fallback reply")); -} - -#[tokio::test] -async fn keyword_provider_prompt_guided_text_wraps_tool_calls_and_honors_fire_limit() { - let provider = KeywordScriptedProvider::new(vec![KeywordRule::tool_call( - "search please", - ScriptedToolCall::new("search_tool", json!({"q": "rust"})), - ) - .with_text("Looking it up.")]); - - let messages = vec![ - ChatMessage::assistant("earlier assistant turn"), - ChatMessage::tool("search please from a tool result"), - ]; - - let first = provider - .chat( - ChatRequest { - messages: &messages, - tools: None, - stream: None, - max_tokens: None, - }, - "test-model", - 0.0, - ) - .await - .expect("prompt-guided response"); - - let text = first.text.expect("prompt-guided text body"); - assert!(first.tool_calls.is_empty()); - assert!(text.starts_with("Looking it up.\n")); - assert!(text.contains("")); - assert!(text.contains("\"name\":\"search_tool\"")); - assert!(text.contains("\"q\":\"rust\"")); - - let second = provider - .chat( - ChatRequest { - messages: &messages, - tools: None, - stream: None, - max_tokens: None, - }, - "test-model", - 0.0, - ) - .await - .expect("fallback after max_fires"); - assert_eq!(second.text.as_deref(), Some("done")); - assert_eq!(provider.turn_count(), 2); -} - -#[tokio::test] -async fn fake_composio_backend_serves_routes_and_uses_response_fallbacks() { - let mut fixture = ComposioFixture::realistic(); - fixture.execute_rules = vec![ComposioExecuteRule::new( - "GMAIL_FETCH_EMAILS", - json!({"messages": [{"id": "gmail-priority-2"}]}), - ) - .when_argument_contains("arguments.query", "release blocker")]; - - let backend = spawn_fake_composio_backend(fixture).await; - let http = reqwest::Client::new(); - - let toolkits: serde_json::Value = http - .get(format!( - "{}/agent-integrations/composio/toolkits", - backend.base_url - )) - .send() - .await - .expect("toolkits request") - .json() - .await - .expect("toolkits json"); - assert_eq!(toolkits["data"]["toolkits"][0], "gmail"); - - let authorize: serde_json::Value = http - .post(format!( - "{}/agent-integrations/composio/authorize", - backend.base_url - )) - .json(&json!({"toolkit": "gmail"})) - .send() - .await - .expect("authorize request") - .json() - .await - .expect("authorize json"); - assert_eq!(authorize["data"]["connectionId"], "conn_gmail_pending",); - - let rule_match: serde_json::Value = http - .post(format!( - "{}/agent-integrations/composio/execute", - backend.base_url - )) - .json(&json!({ - "tool": "GMAIL_FETCH_EMAILS", - "arguments": {"query": "Need RELEASE BLOCKER updates"}, - })) - .send() - .await - .expect("execute request") - .json() - .await - .expect("execute json"); - assert_eq!( - rule_match["data"]["data"]["messages"][0]["id"], - "gmail-priority-2", - ); - - let execute_fallback: serde_json::Value = http - .post(format!( - "{}/agent-integrations/composio/execute", - backend.base_url - )) - .json(&json!({ - "tool": "GMAIL_FETCH_EMAILS", - "arguments": {"page": 1}, - })) - .send() - .await - .expect("execute fallback request") - .json() - .await - .expect("execute fallback json"); - assert_eq!(execute_fallback["data"]["data"]["messages"][0]["id"], "m1",); - - let default_execute: serde_json::Value = http - .post(format!( - "{}/agent-integrations/composio/execute", - backend.base_url - )) - .json(&json!({ - "tool": "UNKNOWN_ACTION", - "arguments": {"topic": "ops"}, - })) - .send() - .await - .expect("default execute request") - .json() - .await - .expect("default execute json"); - assert_eq!(default_execute["data"]["data"]["ok"], true); - assert_eq!(default_execute["data"]["data"]["action"], "UNKNOWN_ACTION"); - - let delete: serde_json::Value = http - .delete(format!( - "{}/agent-integrations/composio/connections/conn_gmail_1", - backend.base_url - )) - .send() - .await - .expect("delete request") - .json() - .await - .expect("delete json"); - assert_eq!(delete["data"]["deleted"], true); - - let requests = backend.requests(); - assert!( - requests - .iter() - .any(|(m, p, _)| m == "GET" && p == "/toolkits"), - "expected toolkits route to be recorded" - ); - assert!( - requests - .iter() - .any(|(m, p, _)| m == "POST" && p == "/authorize"), - "expected authorize route to be recorded" - ); - assert!( - requests - .iter() - .any(|(m, p, body)| m == "POST" && p == "/execute" && body["tool"] == "UNKNOWN_ACTION"), - "expected execute route to record unknown action body" - ); - assert!( - requests - .iter() - .any(|(m, p, _)| m == "DELETE" && p == "/connections/conn_gmail_1"), - "expected delete route to be recorded" - ); -} - -/// Generic test tool: records the args it was called with and returns -/// whatever was wired at construction. -struct RecordingTool { - name_str: String, - description_str: String, - result: ToolResult, - calls: Arc>>, - permission: PermissionLevel, - scope_v: ToolScope, - category_v: ToolCategory, -} - -impl RecordingTool { - fn echo(name: &str) -> (Self, Arc>>) { - let calls = Arc::new(parking_lot::Mutex::new(Vec::new())); - let tool = Self { - name_str: name.to_string(), - description_str: format!("recording tool {name}"), - result: ToolResult::success(format!("{name}-ok")), - calls: calls.clone(), - permission: PermissionLevel::ReadOnly, - scope_v: ToolScope::All, - category_v: ToolCategory::System, - }; - (tool, calls) - } -} - -struct SequencedTool { - name_str: String, - result: ToolResult, - calls: Arc>>, - sequence: Arc>>, -} - -impl SequencedTool { - fn new( - name: &str, - result: ToolResult, - sequence: Arc>>, - ) -> (Self, Arc>>) { - let calls = Arc::new(parking_lot::Mutex::new(Vec::new())); - ( - Self { - name_str: name.to_string(), - result, - calls: calls.clone(), - sequence, - }, - calls, - ) - } -} - -#[async_trait] -impl Tool for SequencedTool { - fn name(&self) -> &str { - &self.name_str - } - fn description(&self) -> &str { - &self.name_str - } - fn parameters_schema(&self) -> serde_json::Value { - json!({"type": "object", "additionalProperties": true}) - } - fn permission_level(&self) -> PermissionLevel { - PermissionLevel::ReadOnly - } - fn scope(&self) -> ToolScope { - ToolScope::All - } - fn category(&self) -> ToolCategory { - ToolCategory::System - } - async fn execute(&self, args: serde_json::Value) -> anyhow::Result { - self.sequence.lock().push(self.name_str.clone()); - self.calls.lock().push(args); - Ok(self.result.clone()) - } -} - -#[async_trait] -impl Tool for RecordingTool { - fn name(&self) -> &str { - &self.name_str - } - fn description(&self) -> &str { - &self.description_str - } - fn parameters_schema(&self) -> serde_json::Value { - json!({"type": "object", "additionalProperties": true}) - } - fn permission_level(&self) -> PermissionLevel { - self.permission - } - fn scope(&self) -> ToolScope { - self.scope_v - } - fn category(&self) -> ToolCategory { - self.category_v - } - async fn execute(&self, args: serde_json::Value) -> anyhow::Result { - self.calls.lock().push(args); - Ok(self.result.clone()) - } -} - -// ── 1. Keyword-driven loop: prompt-guided XML path ──────────────── - -#[tokio::test] -async fn keyword_provider_drives_prompt_guided_tool_loop_to_completion() { - let provider = KeywordScriptedProvider::new(vec![ - KeywordRule::tool_call( - "search", - ScriptedToolCall::new("search_tool", json!({"q": "rust"})), - ) - .with_text("Looking it up."), - KeywordRule::final_reply("search_tool-ok", "Here is the answer."), - ]); - - let (search_tool, search_calls) = RecordingTool::echo("search_tool"); - let tools: Vec> = vec![Box::new(search_tool)]; - - let mut history = vec![ChatMessage::user("please search the web for rust news")]; - - let result = run_tool_call_loop( - &provider, - &mut history, - &tools, - "mock", - "test-model", - 0.0, - true, - "channel", - &mm(), - &mff(), - 5, - None, - None, - &[], - None, - None, - &crate::openhuman::tools::policy::DefaultToolPolicy, - ) - .await - .expect("loop should complete"); - - assert_eq!(result, "Here is the answer."); - assert_eq!( - search_calls.lock().len(), - 1, - "tool should fire exactly once" - ); - assert_eq!(search_calls.lock()[0]["q"], "rust"); - assert!(provider.turn_count() >= 2); -} - -// ── 2. Keyword-driven loop: native tool_calls path ──────────────── - -#[tokio::test] -async fn keyword_provider_drives_native_tool_calls_path() { - let provider = KeywordScriptedProvider::new(vec![ - KeywordRule::tool_call( - "weather", - ScriptedToolCall::new("weather_tool", json!({"city": "Berlin"})), - ), - KeywordRule::final_reply("weather_tool-ok", "It's sunny."), - ]) - .with_native_tools(true); - - let (weather_tool, weather_calls) = RecordingTool::echo("weather_tool"); - let tools: Vec> = vec![Box::new(weather_tool)]; - - let mut history = vec![ChatMessage::user("what's the weather in Berlin?")]; - - let out = run_tool_call_loop( - &provider, - &mut history, - &tools, - "mock-native", - "test-model", - 0.0, - true, - "channel", - &mm(), - &mff(), - 5, - None, - None, - &[], - None, - None, - &crate::openhuman::tools::policy::DefaultToolPolicy, - ) - .await - .expect("loop should complete"); - - assert_eq!(out, "It's sunny."); - assert_eq!(weather_calls.lock().len(), 1); - // History should contain a tool role message (native path) referencing the call id. - assert!(history.iter().any(|m| m.role == "tool")); - let tool_msg = history.iter().find(|m| m.role == "tool").unwrap(); - assert!( - tool_msg.content.contains("weather_tool-ok"), - "tool result should be threaded through history: {}", - tool_msg.content - ); -} - -// ── 3. Multi-tool chain via successive keyword matches ──────────── - -#[tokio::test] -async fn keyword_provider_chains_multiple_tools_across_iterations() { - let provider = KeywordScriptedProvider::new(vec![ - KeywordRule::tool_call( - "draft an email", - ScriptedToolCall::new("draft_tool", json!({"to": "alice@example.com"})), - ), - KeywordRule::tool_call( - "draft_tool-ok", - ScriptedToolCall::new("send_tool", json!({"draft_id": "d-1"})), - ), - KeywordRule::final_reply("send_tool-ok", "Email sent to alice."), - ]); - - let (draft_tool, draft_calls) = RecordingTool::echo("draft_tool"); - let (send_tool, send_calls) = RecordingTool::echo("send_tool"); - let tools: Vec> = vec![Box::new(draft_tool), Box::new(send_tool)]; - - let mut history = vec![ChatMessage::user("draft an email to alice")]; - - let out = run_tool_call_loop( - &provider, - &mut history, - &tools, - "mock", - "test-model", - 0.0, - true, - "channel", - &mm(), - &mff(), - 10, - None, - None, - &[], - None, - None, - &crate::openhuman::tools::policy::DefaultToolPolicy, - ) - .await - .unwrap(); - - assert_eq!(out, "Email sent to alice."); - assert_eq!(draft_calls.lock().len(), 1); - assert_eq!(send_calls.lock().len(), 1); -} - -// ── 4. Crypto wallet flow: inspect → quote → confirm → execute ─── - -#[tokio::test] -async fn crypto_wallet_send_flow_sequences_wallet_tools_and_confirmation_gate() { - let provider = KeywordScriptedProvider::new(vec![ - KeywordRule::tool_call( - "send john $5", - ScriptedToolCall::new("wallet_status", json!({})), - ), - KeywordRule::tool_call( - "wallet_status-ok", - ScriptedToolCall::new("wallet_balances", json!({})), - ), - KeywordRule::tool_call( - "wallet_balances-ok", - ScriptedToolCall::new("wallet_chain_status", json!({})), - ), - KeywordRule::tool_call( - "wallet_chain_status-ok", - ScriptedToolCall::new( - "wallet_prepare_transfer", - json!({ - "chain": "evm", - "to_address": "0x00000000000000000000000000000000000000aa", - "amount_raw": "5000000", - }), - ), - ), - KeywordRule::tool_call( - "wallet_prepare_transfer-ok", - ScriptedToolCall::new( - "ask_user_clarification", - json!({"question": "Send $5 to John on EVM?"}), - ), - ), - KeywordRule::tool_call( - "ask_user_clarification-ok", - ScriptedToolCall::new( - "wallet_execute_prepared", - json!({"quoteId": "q_test_send_john", "confirmed": true}), - ), - ), - KeywordRule::final_reply( - "wallet_execute_prepared-ok", - "Prepared quote confirmed and handed to the wallet signer.", - ), - ]) - .with_native_tools(true); - - let sequence = Arc::new(parking_lot::Mutex::new(Vec::new())); - let (wallet_status, wallet_status_calls) = SequencedTool::new( - "wallet_status", - ToolResult::success("wallet_status-ok"), - sequence.clone(), - ); - let (wallet_balances, wallet_balances_calls) = SequencedTool::new( - "wallet_balances", - ToolResult::success("wallet_balances-ok"), - sequence.clone(), - ); - let (wallet_chain_status, wallet_chain_status_calls) = SequencedTool::new( - "wallet_chain_status", - ToolResult::success("wallet_chain_status-ok"), - sequence.clone(), - ); - let (wallet_prepare_transfer, wallet_prepare_transfer_calls) = SequencedTool::new( - "wallet_prepare_transfer", - ToolResult::success("wallet_prepare_transfer-ok"), - sequence.clone(), - ); - let (ask_user_clarification, ask_user_clarification_calls) = SequencedTool::new( - "ask_user_clarification", - ToolResult::success("ask_user_clarification-ok"), - sequence.clone(), - ); - let (wallet_execute_prepared, wallet_execute_prepared_calls) = SequencedTool::new( - "wallet_execute_prepared", - ToolResult::success("wallet_execute_prepared-ok"), - sequence.clone(), - ); - - let tools: Vec> = vec![ - Box::new(wallet_status), - Box::new(wallet_balances), - Box::new(wallet_chain_status), - Box::new(wallet_prepare_transfer), - Box::new(ask_user_clarification), - Box::new(wallet_execute_prepared), - ]; - let mut history = vec![ChatMessage::user("Please send John $5 on EVM.")]; - - let out = run_tool_call_loop( - &provider, - &mut history, - &tools, - "mock-native", - "test-model", - 0.0, - true, - "web", - &mm(), - &mff(), - 10, - None, - None, - &[], - None, - None, - &crate::openhuman::tools::policy::DefaultToolPolicy, - ) - .await - .expect("crypto wallet flow should complete"); - - assert_eq!( - out, - "Prepared quote confirmed and handed to the wallet signer." - ); - assert_eq!( - sequence.lock().clone(), - vec![ - "wallet_status", - "wallet_balances", - "wallet_chain_status", - "wallet_prepare_transfer", - "ask_user_clarification", - "wallet_execute_prepared", - ] - ); - assert_eq!(wallet_status_calls.lock().len(), 1); - assert_eq!(wallet_balances_calls.lock().len(), 1); - assert_eq!(wallet_chain_status_calls.lock().len(), 1); - assert_eq!( - wallet_prepare_transfer_calls.lock()[0], - json!({ - "chain": "evm", - "to_address": "0x00000000000000000000000000000000000000aa", - "amount_raw": "5000000", - }) - ); - assert_eq!( - ask_user_clarification_calls.lock()[0]["question"], - "Send $5 to John on EVM?" - ); - assert_eq!( - wallet_execute_prepared_calls.lock()[0], - json!({"quoteId": "q_test_send_john", "confirmed": true}) - ); -} - -#[tokio::test] -async fn crypto_wallet_send_flow_does_not_execute_when_confirmation_is_not_granted() { - let provider = KeywordScriptedProvider::new(vec![ - KeywordRule::tool_call( - "send john $5", - ScriptedToolCall::new("wallet_status", json!({})), - ), - KeywordRule::tool_call( - "wallet_status-ok", - ScriptedToolCall::new("wallet_prepare_transfer", json!({"chain": "evm"})), - ), - KeywordRule::tool_call( - "wallet_prepare_transfer-ok", - ScriptedToolCall::new( - "ask_user_clarification", - json!({"question": "Confirm the transfer?"}), - ), - ), - KeywordRule::final_reply( - "user declined the transfer", - "Cancelled before execution because the user did not confirm.", - ), - ]) - .with_native_tools(true); - - let sequence = Arc::new(parking_lot::Mutex::new(Vec::new())); - let (wallet_status, _) = SequencedTool::new( - "wallet_status", - ToolResult::success("wallet_status-ok"), - sequence.clone(), - ); - let (wallet_prepare_transfer, _) = SequencedTool::new( - "wallet_prepare_transfer", - ToolResult::success("wallet_prepare_transfer-ok"), - sequence.clone(), - ); - let (ask_user_clarification, _) = SequencedTool::new( - "ask_user_clarification", - ToolResult::success("user declined the transfer"), - sequence.clone(), - ); - let (wallet_execute_prepared, wallet_execute_prepared_calls) = SequencedTool::new( - "wallet_execute_prepared", - ToolResult::success("wallet_execute_prepared-ok"), - sequence.clone(), - ); - - let tools: Vec> = vec![ - Box::new(wallet_status), - Box::new(wallet_prepare_transfer), - Box::new(ask_user_clarification), - Box::new(wallet_execute_prepared), - ]; - let mut history = vec![ChatMessage::user("Please send John $5 on EVM.")]; - - let out = run_tool_call_loop( - &provider, - &mut history, - &tools, - "mock-native", - "test-model", - 0.0, - true, - "telegram", - &mm(), - &mff(), - 8, - None, - None, - &[], - None, - None, - &crate::openhuman::tools::policy::DefaultToolPolicy, - ) - .await - .expect("declined flow should still complete"); - - assert_eq!( - out, - "Cancelled before execution because the user did not confirm." - ); - assert_eq!( - sequence.lock().clone(), - vec![ - "wallet_status", - "wallet_prepare_transfer", - "ask_user_clarification", - ] - ); - assert!( - wallet_execute_prepared_calls.lock().is_empty(), - "execute tool must not run after a declined confirmation" - ); -} - -#[tokio::test] -async fn keyword_provider_uses_latest_tool_result_to_drive_the_next_tool_call() { - let provider = KeywordScriptedProvider::new(vec![ - KeywordRule::tool_call( - "start lookup", - ScriptedToolCall::new("lookup_tool", json!({"symbol": "BTC"})), - ), - KeywordRule::tool_call( - "lookup_tool-ok", - ScriptedToolCall::new("enrich_tool", json!({"source": "lookup"})), - ), - KeywordRule::final_reply("enrich_tool-ok", "Finished after the second tool."), - ]) - .with_native_tools(true); - - let (lookup_tool, lookup_calls) = RecordingTool::echo("lookup_tool"); - let (enrich_tool, enrich_calls) = RecordingTool::echo("enrich_tool"); - let tools: Vec> = vec![Box::new(lookup_tool), Box::new(enrich_tool)]; - - let mut history = vec![ChatMessage::user("please start lookup for BTC")]; - - let out = run_tool_call_loop( - &provider, - &mut history, - &tools, - "mock-native", - "test-model", - 0.0, - true, - "channel", - &mm(), - &mff(), - 10, - None, - None, - &[], - None, - None, - &crate::openhuman::tools::policy::DefaultToolPolicy, - ) - .await - .expect("loop should complete"); - - assert_eq!(out, "Finished after the second tool."); - assert_eq!(lookup_calls.lock().as_slice(), &[json!({"symbol": "BTC"})]); - assert_eq!( - enrich_calls.lock().as_slice(), - &[json!({"source": "lookup"})] - ); - - let turns = provider.turns(); - assert_eq!( - turns.len(), - 3, - "expected two tool turns and one final reply" - ); - assert_eq!(turns[0].rule_keyword.as_deref(), Some("start lookup")); - assert_eq!(turns[1].rule_keyword.as_deref(), Some("lookup_tool-ok")); - assert_eq!(turns[2].rule_keyword.as_deref(), Some("enrich_tool-ok")); - - let second_turn_probe = turns[1] - .messages - .iter() - .rev() - .find(|msg| msg.role == "tool") - .map(|msg| msg.content.clone()) - .unwrap_or_default(); - assert!( - second_turn_probe.contains("lookup_tool-ok"), - "second turn should be driven by the first tool result, got: {second_turn_probe}" - ); -} - -#[tokio::test] -async fn keyword_provider_executes_multiple_native_tool_calls_from_one_turn() { - let provider = KeywordScriptedProvider::new(vec![ - KeywordRule { - keyword: "do both".to_string(), - tool_calls: vec![ - ScriptedToolCall::new("lookup_tool", json!({"symbol": "BTC"})), - ScriptedToolCall::new("enrich_tool", json!({"source": "coinbase"})), - ], - final_text: Some("Running both tools.".to_string()), - max_fires: Some(1), - }, - KeywordRule::final_reply("enrich_tool-ok", "Both tools completed."), - ]) - .with_native_tools(true); - - let (lookup_tool, lookup_calls) = RecordingTool::echo("lookup_tool"); - let (enrich_tool, enrich_calls) = RecordingTool::echo("enrich_tool"); - let tools: Vec> = vec![Box::new(lookup_tool), Box::new(enrich_tool)]; - - let mut history = vec![ChatMessage::user("please do both actions now")]; - - let out = run_tool_call_loop( - &provider, - &mut history, - &tools, - "mock-native", - "test-model", - 0.0, - true, - "channel", - &mm(), - &mff(), - 10, - None, - None, - &[], - None, - None, - &crate::openhuman::tools::policy::DefaultToolPolicy, - ) - .await - .expect("loop should complete"); - - assert_eq!(out, "Both tools completed."); - assert_eq!(lookup_calls.lock().as_slice(), &[json!({"symbol": "BTC"})]); - assert_eq!( - enrich_calls.lock().as_slice(), - &[json!({"source": "coinbase"})] - ); - - let turns = provider.turns(); - assert_eq!(turns[0].emitted_tool_calls.len(), 2); - assert_eq!(turns[0].emitted_tool_calls[0].name, "lookup_tool"); - assert_eq!(turns[0].emitted_tool_calls[1].name, "enrich_tool"); -} - -// ── 6. Unknown tool name handled gracefully ─────────────────────── - -#[tokio::test] -async fn keyword_provider_unknown_tool_surfaces_error_and_loop_continues() { - let provider = KeywordScriptedProvider::new(vec![ - KeywordRule::tool_call("go", ScriptedToolCall::new("nonexistent_tool", json!({}))), - // After we see "Unknown tool" in the role=tool injection, give up. - KeywordRule::final_reply("unknown tool", "Sorry, I can't do that."), - ]); - - let tools: Vec> = vec![]; - - let mut history = vec![ChatMessage::user("go go go")]; - - let out = run_tool_call_loop( - &provider, - &mut history, - &tools, - "mock", - "test-model", - 0.0, - true, - "channel", - &mm(), - &mff(), - 5, - None, - None, - &[], - None, - None, - &crate::openhuman::tools::policy::DefaultToolPolicy, - ) - .await - .unwrap(); - - assert_eq!(out, "Sorry, I can't do that."); - // The Tool Results message should record the "Unknown tool: nonexistent_tool". - assert!(history.iter().any(|m| m.content.contains("Unknown tool"))); -} - -// ── 5. Max iterations guard ─────────────────────────────────────── - -#[tokio::test] -async fn run_tool_call_loop_returns_max_iterations_error() { - // Configure a rule that keeps firing forever — but cap iterations. - let provider = KeywordScriptedProvider::new(vec![KeywordRule { - keyword: "echo-ok".to_string(), // matches tool result, so it loops - tool_calls: vec![ScriptedToolCall::new("echo", json!({}))], - final_text: None, - max_fires: None, - }]) - // First turn: kick it off - .with_fallback("end"); - // Vary the args each turn so the repeat-CALL breaker (which halts identical - // (tool,args) loops) doesn't fire before the iteration cap — this test - // exercises the max-iterations error path specifically. - for i in 0..6 { - provider.push_forced_response(ChatResponse { - text: Some(format!( - "{{\"name\":\"echo\",\"arguments\":{{\"i\":{i}}}}}" - )), - tool_calls: vec![], - usage: None, - reasoning_content: None, - }); - } - - let (echo_tool, _) = RecordingTool::echo("echo"); - let tools: Vec> = vec![Box::new(echo_tool)]; - let mut history = vec![ChatMessage::user("loop forever")]; - - let err = run_tool_call_loop( - &provider, - &mut history, - &tools, - "mock", - "test-model", - 0.0, - true, - "channel", - &mm(), - &mff(), - 3, - None, - None, - &[], - None, - None, - &crate::openhuman::tools::policy::DefaultToolPolicy, - ) - .await - .expect_err("should hit max iterations"); - - let s = err.to_string(); - assert!( - s.contains("3") && s.to_lowercase().contains("iteration"), - "expected MaxIterationsExceeded with 3, got: {s}" - ); -} - -// ── 6. CliRpcOnly tools are blocked in the agent loop ───────────── - -struct CliOnlyTool { - calls: Arc, -} - -#[async_trait] -impl Tool for CliOnlyTool { - fn name(&self) -> &str { - "cli_only_tool" - } - fn description(&self) -> &str { - "cli-only" - } - fn parameters_schema(&self) -> serde_json::Value { - json!({"type": "object"}) - } - fn scope(&self) -> ToolScope { - ToolScope::CliRpcOnly - } - async fn execute(&self, _args: serde_json::Value) -> anyhow::Result { - self.calls.fetch_add(1, Ordering::SeqCst); - Ok(ToolResult::success("ran")) - } -} - -#[tokio::test] -async fn agent_loop_refuses_clirpconly_tools() { - let calls = Arc::new(AtomicUsize::new(0)); - let tool = CliOnlyTool { - calls: calls.clone(), - }; - - let provider = KeywordScriptedProvider::new(vec![ - KeywordRule::tool_call("use", ScriptedToolCall::new("cli_only_tool", json!({}))), - KeywordRule::final_reply("only available via", "Denied as expected."), - ]); - - let tools: Vec> = vec![Box::new(tool)]; - let mut history = vec![ChatMessage::user("use the tool")]; - - let out = run_tool_call_loop( - &provider, - &mut history, - &tools, - "mock", - "test-model", - 0.0, - true, - "channel", - &mm(), - &mff(), - 5, - None, - None, - &[], - None, - None, - &crate::openhuman::tools::policy::DefaultToolPolicy, - ) - .await - .unwrap(); - - assert_eq!(out, "Denied as expected."); - assert_eq!( - calls.load(Ordering::SeqCst), - 0, - "CliRpcOnly tool must never execute in the agent loop" - ); -} - -// ── 7. Tool error result is threaded back as `Error: …` ─────────── - -struct FailingTool; - -#[async_trait] -impl Tool for FailingTool { - fn name(&self) -> &str { - "fail_tool" - } - fn description(&self) -> &str { - "always fails" - } - fn parameters_schema(&self) -> serde_json::Value { - json!({"type": "object"}) - } - async fn execute(&self, _args: serde_json::Value) -> anyhow::Result { - Ok(ToolResult::error("boom")) - } -} - -#[tokio::test] -async fn tool_error_result_is_surfaced_to_next_iteration() { - let provider = KeywordScriptedProvider::new(vec![ - KeywordRule::tool_call("try", ScriptedToolCall::new("fail_tool", json!({}))), - KeywordRule::final_reply("boom", "got the error"), - ]); - - let tools: Vec> = vec![Box::new(FailingTool)]; - let mut history = vec![ChatMessage::user("try the broken tool")]; - - let out = run_tool_call_loop( - &provider, - &mut history, - &tools, - "mock", - "test-model", - 0.0, - true, - "channel", - &mm(), - &mff(), - 5, - None, - None, - &[], - None, - None, - &crate::openhuman::tools::policy::DefaultToolPolicy, - ) - .await - .unwrap(); - - assert_eq!(out, "got the error"); - assert!(history.iter().any(|m| m.content.contains("Error: boom"))); -} - -// ── 8. Tool that bails with anyhow::Error ───────────────────────── - -struct PanickyTool; - -#[async_trait] -impl Tool for PanickyTool { - fn name(&self) -> &str { - "panicky" - } - fn description(&self) -> &str { - "raises anyhow" - } - fn parameters_schema(&self) -> serde_json::Value { - json!({"type": "object"}) - } - async fn execute(&self, _args: serde_json::Value) -> anyhow::Result { - anyhow::bail!("kaboom") - } -} - -#[tokio::test] -async fn tool_anyhow_error_surfaces_in_history() { - let provider = KeywordScriptedProvider::new(vec![ - KeywordRule::tool_call("run", ScriptedToolCall::new("panicky", json!({}))), - KeywordRule::final_reply("kaboom", "tool blew up"), - ]); - - let tools: Vec> = vec![Box::new(PanickyTool)]; - let mut history = vec![ChatMessage::user("run it")]; - - let out = run_tool_call_loop( - &provider, - &mut history, - &tools, - "mock", - "test-model", - 0.0, - true, - "channel", - &mm(), - &mff(), - 5, - None, - None, - &[], - None, - None, - &crate::openhuman::tools::policy::DefaultToolPolicy, - ) - .await - .unwrap(); - - assert_eq!(out, "tool blew up"); - assert!(history.iter().any(|m| m.content.contains("kaboom"))); -} - -// ── 9. visible_tool_names whitelist hides tools from the model ──── - -#[tokio::test] -async fn visible_tool_names_whitelist_rejects_filtered_out_tools() { - let provider = KeywordScriptedProvider::new(vec![ - // Model asks for a tool that *exists* but is filtered out. - KeywordRule::tool_call("go", ScriptedToolCall::new("hidden", json!({}))), - KeywordRule::final_reply("unknown tool", "Cannot reach hidden tool."), - ]); - - let (visible_tool, visible_calls) = RecordingTool::echo("visible"); - let (hidden_tool, hidden_calls) = RecordingTool::echo("hidden"); - let tools: Vec> = vec![Box::new(visible_tool), Box::new(hidden_tool)]; - - let mut visible = std::collections::HashSet::new(); - visible.insert("visible".to_string()); - - let mut history = vec![ChatMessage::user("go please")]; - - let out = run_tool_call_loop( - &provider, - &mut history, - &tools, - "mock", - "test-model", - 0.0, - true, - "channel", - &mm(), - &mff(), - 5, - None, - Some(&visible), - &[], - None, - None, - &crate::openhuman::tools::policy::DefaultToolPolicy, - ) - .await - .unwrap(); - - assert_eq!(out, "Cannot reach hidden tool."); - assert_eq!(visible_calls.lock().len(), 0); - assert_eq!( - hidden_calls.lock().len(), - 0, - "hidden tool must not execute when filtered out" - ); -} - -// ── 10. extra_tools are reachable alongside the registry ────────── - -#[tokio::test] -async fn extra_tools_are_invokable_alongside_registry() { - let provider = KeywordScriptedProvider::new(vec![ - KeywordRule::tool_call("delegate", ScriptedToolCall::new("extra", json!({"x": 1}))), - KeywordRule::final_reply("extra-ok", "delegated"), - ]); - - let (extra_tool, extra_calls) = RecordingTool::echo("extra"); - let extras: Vec> = vec![Box::new(extra_tool)]; - - let tools: Vec> = vec![]; - let mut history = vec![ChatMessage::user("delegate the work")]; - - let out = run_tool_call_loop( - &provider, - &mut history, - &tools, - "mock", - "test-model", - 0.0, - true, - "channel", - &mm(), - &mff(), - 5, - None, - None, - &extras, - None, - None, - &crate::openhuman::tools::policy::DefaultToolPolicy, - ) - .await - .unwrap(); - - assert_eq!(out, "delegated"); - assert_eq!(extra_calls.lock().len(), 1); -} - -// ── 11. Composio fixture backend: end-to-end client wiring ──────── - -#[tokio::test] -async fn fake_composio_backend_serves_realistic_toolkits() { - let backend = spawn_fake_composio_backend(ComposioFixture::realistic()).await; - let client = backend.client(); - - let toolkits = client.list_toolkits().await.unwrap(); - assert!(toolkits.toolkits.contains(&"gmail".to_string())); - assert!(toolkits.toolkits.contains(&"github".to_string())); - - let conns = client.list_connections().await.unwrap(); - assert!( - conns.connections.iter().any(|c| c.toolkit == "gmail"), - "expected a gmail connection: {:?}", - conns.connections - ); - - let exec = client - .execute_tool( - "GMAIL_SEND_EMAIL", - Some(json!({"recipient_email": "a@b.com", "subject": "hi", "body": "hello"})), - ) - .await - .unwrap(); - assert!(exec.successful, "execute should report success"); - let resp_json = serde_json::to_value(&exec.data).unwrap(); - assert!( - resp_json.to_string().contains("gmail-msg-1234"), - "expected fixture response, got: {resp_json}" - ); - - let reqs = backend.requests(); - // Authorize wasn't called; toolkits + connections + execute were. - assert!(reqs.iter().any(|(_, p, _)| p == "/toolkits")); - assert!(reqs.iter().any(|(_, p, _)| p == "/connections")); - assert!(reqs.iter().any(|(_, p, _)| p == "/execute")); -} - -#[tokio::test] -async fn fake_composio_backend_can_match_execute_rules_by_argument_content() { - let mut fixture = ComposioFixture::realistic(); - fixture.execute_rules.push( - ComposioExecuteRule::new( - "GMAIL_FETCH_EMAILS", - json!({ - "messages": [ - { - "id": "gmail-priority-1", - "subject": "Release blocker", - "snippet": "The release blocker is the broken memory recall spec." - } - ] - }), - ) - .when_argument_contains("arguments.query", "release blocker"), - ); - let backend = spawn_fake_composio_backend(fixture).await; - let client = backend.client(); - - let exec = client - .execute_tool( - "GMAIL_FETCH_EMAILS", - Some(json!({ - "query": "label:inbox release blocker", - "max_results": 5 - })), - ) - .await - .unwrap(); - - assert!(exec.successful, "execute should report success"); - let resp_json = serde_json::to_value(&exec.data).unwrap(); - assert!( - resp_json.to_string().contains("gmail-priority-1"), - "expected rule-driven response, got: {resp_json}" - ); - let reqs = backend.requests(); - let exec_req = reqs - .iter() - .find(|(method, path, _)| method == "POST" && path == "/execute") - .expect("execute request should be recorded"); - assert_eq!( - exec_req.2["arguments"]["query"], - "label:inbox release blocker" - ); -} - -// ── 12. End-to-end: harness drives a Composio tool against fake backend - -#[tokio::test] -async fn harness_invokes_composio_action_tool_against_fake_backend() { - use crate::openhuman::composio::ComposioActionTool; - use crate::openhuman::config::TEST_ENV_LOCK; - - // Post-#1710-Wave-4, `ComposioActionTool::execute` reloads config via - // `load_config_with_timeout()` per call, so the injected `Arc` - // only routes to the fake backend if it is the live on-disk config. - // Hold `TEST_ENV_LOCK` and point `OPENHUMAN_WORKSPACE` at the - // persisted fake-backend workspace. - let _env_guard = TEST_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); - - let backend = spawn_fake_composio_backend(ComposioFixture::realistic()).await; - let (config, workspace_root) = backend.config_persisted().await; - unsafe { - std::env::set_var("OPENHUMAN_WORKSPACE", &workspace_root); - } - - let tool = ComposioActionTool::new( - config, - "GMAIL_SEND_EMAIL".to_string(), - "Send a Gmail email".to_string(), - Some(json!({"type": "object"})), - ); - - let provider = KeywordScriptedProvider::new(vec![ - KeywordRule::tool_call( - "send email", - ScriptedToolCall::new( - "GMAIL_SEND_EMAIL", - json!({"recipient_email": "alice@example.com", "subject": "hi", "body": "hello"}), - ), - ), - KeywordRule::final_reply("gmail-msg-1234", "Email sent."), - ]); - - let tools: Vec> = vec![Box::new(tool)]; - let mut history = vec![ChatMessage::user("send email to alice")]; - - let out = run_tool_call_loop( - &provider, - &mut history, - &tools, - "mock", - "test-model", - 0.0, - true, - "channel", - &mm(), - &mff(), - 5, - None, - None, - &[], - None, - None, - &crate::openhuman::tools::policy::DefaultToolPolicy, - ) - .await - .unwrap(); - - assert_eq!(out, "Email sent."); - // Backend should have received the execute POST. - let reqs = backend.requests(); - let exec = reqs - .iter() - .find(|(m, p, _)| m == "POST" && p == "/execute") - .expect("execute call must have hit the backend"); - assert_eq!(exec.2["tool"], "GMAIL_SEND_EMAIL"); - assert_eq!(exec.2["arguments"]["recipient_email"], "alice@example.com"); - - unsafe { - std::env::remove_var("OPENHUMAN_WORKSPACE"); - } -} - -// ── 13. Orchestrator-prompt → delegation → Composio round-trip ──── -// -// End-to-end test that ties together the three things the user needs -// confidence in: -// -// 1. The orchestrator's system prompt (built by -// `orchestrator::prompt::build`) advertises a connected toolkit -// via the collapsed `delegate_to_integrations_agent` tool. -// 2. Given that prompt and a user task that mentions the toolkit, -// the LLM (mocked) emits a tool call that satisfies the real -// `SkillDelegationTool` schema. -// 3. Delegation reaches the integrations side, where a -// `ComposioActionTool` is dispatched against a real -// `ComposioClient` pointed at a hermetic fake backend — and the -// backend records the action with the orchestrator-provided args. -// -// To avoid pulling in the full sub-agent runner, the test substitutes -// a `TestDelegationTool` that mirrors `SkillDelegationTool`'s contract -// (same tool name, same schema validation against the connected -// toolkit list) but runs a *nested* `run_tool_call_loop` for the -// integrations side instead of calling `dispatch_subagent`. The -// nested loop is the same code path the real integrations_agent uses, -// so the wiring under test is the orchestrator → delegation arg → -// integrations LLM → ComposioActionTool → backend chain. - -struct TestDelegationTool { - connected_toolkits: Vec<(String, String)>, - nested_tools: Arc>>>>, - inner_provider: Arc, -} - -impl TestDelegationTool { - fn new( - connected_toolkits: Vec<(String, String)>, - nested_tools: Vec>, - inner_provider: Arc, - ) -> Self { - Self { - connected_toolkits, - nested_tools: Arc::new(parking_lot::Mutex::new(Some(nested_tools))), - inner_provider, - } - } -} - -#[async_trait] -impl Tool for TestDelegationTool { - fn name(&self) -> &str { - // Mirror SkillDelegationTool's canonical name so the orchestrator - // system prompt's references resolve. - "delegate_to_integrations_agent" - } - fn description(&self) -> &str { - "Delegate to integrations_agent (test stand-in for SkillDelegationTool)." - } - fn parameters_schema(&self) -> serde_json::Value { - // Same shape as the real SkillDelegationTool. - let slugs: Vec<&str> = self - .connected_toolkits - .iter() - .map(|(s, _)| s.as_str()) - .collect(); - json!({ - "type": "object", - "required": ["toolkit", "prompt"], - "properties": { - "toolkit": {"type": "string", "enum": slugs}, - "prompt": {"type": "string"} - } - }) - } - async fn execute(&self, args: serde_json::Value) -> anyhow::Result { - let toolkit = args - .get("toolkit") - .and_then(|v| v.as_str()) - .unwrap_or("") - .trim() - .to_string(); - let prompt = args - .get("prompt") - .and_then(|v| v.as_str()) - .unwrap_or("") - .trim() - .to_string(); - if toolkit.is_empty() { - return Ok(ToolResult::error("`toolkit` is required")); - } - let known = self - .connected_toolkits - .iter() - .any(|(slug, _)| slug == &toolkit); - if !known { - return Ok(ToolResult::error(format!( - "toolkit `{toolkit}` is not connected" - ))); - } - if prompt.is_empty() { - return Ok(ToolResult::error("`prompt` is required")); - } - - // Take ownership of the nested tool list (one-shot). - let nested_tools = self - .nested_tools - .lock() - .take() - .expect("nested tools already consumed"); - - // Run a NESTED tool loop — same code path the integrations_agent - // uses inside the real sub-agent runner. - let mut nested_history = vec![ChatMessage::user(format!("[toolkit={toolkit}] {prompt}"))]; - let out = run_tool_call_loop( - self.inner_provider.as_ref(), - &mut nested_history, - &nested_tools, - "test-integrations", - "test-model", - 0.0, - true, - "channel", - &mm(), - &mff(), - 5, - None, - None, - &[], - None, - None, - &crate::openhuman::tools::policy::DefaultToolPolicy, - ) - .await?; - - Ok(ToolResult::success(out)) - } -} - -#[tokio::test] -async fn orchestrator_prompt_drives_composio_call_via_delegation_chain() { - use crate::openhuman::agent::prompts::types::ConnectedIntegration; - use crate::openhuman::agent_registry::agents::orchestrator::prompt as orch_prompt; - use crate::openhuman::composio::ComposioActionTool; - use crate::openhuman::config::TEST_ENV_LOCK; - - // Post-#1710-Wave-4, `ComposioActionTool::execute` reloads config via - // `load_config_with_timeout()` per call. Hold `TEST_ENV_LOCK` and - // point `OPENHUMAN_WORKSPACE` at the persisted fake-backend - // workspace so the tool routes to the fake backend. - let _env_guard = TEST_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); - - // ── 1. Build the orchestrator's system prompt with gmail wired in. - let integrations = vec![ConnectedIntegration { - toolkit: "gmail".into(), - description: "Email send/fetch via Gmail.".into(), - tools: Vec::new(), - gated_tools: Vec::new(), - connected: true, - connections: Vec::new(), - non_active_status: None, - }]; - let ctx = { - use crate::openhuman::context::prompt::{LearnedContextData, ToolCallFormat}; - use std::collections::HashSet; - use std::sync::OnceLock; - static EMPTY: OnceLock> = OnceLock::new(); - crate::openhuman::context::prompt::PromptContext { - workspace_dir: std::path::Path::new("."), - model_name: "test", - agent_id: "orchestrator", - tools: &[], - workflows: &[], - dispatcher_instructions: "", - learned: LearnedContextData::default(), - visible_tool_names: EMPTY.get_or_init(HashSet::new), - tool_call_format: ToolCallFormat::PFormat, - connected_integrations: &integrations, - connected_identities_md: String::new(), - include_profile: false, - include_memory_md: false, - curated_snapshot: None, - user_identity: None, - personality_soul_md: None, - personality_memory_md: None, - personality_roster: vec![], - } - }; - let system_prompt = orch_prompt::build(&ctx).expect("build orchestrator prompt"); - // The prompt must explicitly route gmail tasks via the collapsed - // delegation tool — otherwise the orchestrator can't know to call it. - assert!(system_prompt.contains("## Connected Integrations")); - assert!(system_prompt.contains("delegate_to_integrations_agent")); - assert!(system_prompt.contains("toolkit: \"gmail\"")); - - // ── 2. Spawn the fake Composio backend + wire a ComposioActionTool. - let backend = spawn_fake_composio_backend(ComposioFixture::realistic()).await; - let (composio_config, workspace_root) = backend.config_persisted().await; - unsafe { - std::env::set_var("OPENHUMAN_WORKSPACE", &workspace_root); - } - let gmail_action_tool: Box = Box::new(ComposioActionTool::new( - composio_config, - "GMAIL_SEND_EMAIL".to_string(), - "Send a Gmail email".to_string(), - Some(json!({"type": "object"})), - )); - - // ── 3. Inner (integrations-side) provider: emit a ComposioActionTool call, - // then a final reply once it sees the action's success marker. - let inner_provider = Arc::new(KeywordScriptedProvider::new(vec![ - KeywordRule::tool_call( - "toolkit=gmail", - ScriptedToolCall::new( - "GMAIL_SEND_EMAIL", - json!({ - "recipient_email": "alice@example.com", - "subject": "hi", - "body": "hello from orchestrator", - }), - ), - ), - KeywordRule::final_reply("gmail-msg-1234", "delivered"), - ])); - - let nested_tools: Vec> = vec![gmail_action_tool]; - - // ── 4. Outer (orchestrator) provider: when the user asks to email Alice - // via gmail, emit the delegation tool call. After the delegation - // returns "delivered", produce the final user-facing reply. - let outer_provider = KeywordScriptedProvider::new(vec![ - KeywordRule::tool_call( - "send an email to alice", - ScriptedToolCall::new( - "delegate_to_integrations_agent", - json!({ - "toolkit": "gmail", - "prompt": "Send an email to alice@example.com saying hi" - }), - ), - ), - KeywordRule::final_reply("delivered", "I've sent the email to Alice."), - ]); - - // ── 5. Wire the test delegation tool that bridges outer -> inner loop. - let delegation_tool: Box = Box::new(TestDelegationTool::new( - vec![( - "gmail".to_string(), - "Email send/fetch via Gmail.".to_string(), - )], - nested_tools, - inner_provider.clone(), - )); - let outer_tools: Vec> = vec![delegation_tool]; - - let mut history = vec![ - ChatMessage::system(system_prompt), - ChatMessage::user("Please send an email to alice@example.com saying hi via gmail."), - ]; - - // ── 6. Drive the outer (orchestrator) loop. - let final_reply = run_tool_call_loop( - &outer_provider, - &mut history, - &outer_tools, - "orchestrator-mock", - "test-model", - 0.0, - true, - "channel", - &mm(), - &mff(), - 5, - None, - None, - &[], - None, - None, - &crate::openhuman::tools::policy::DefaultToolPolicy, - ) - .await - .expect("orchestrator loop should complete"); - - assert_eq!(final_reply, "I've sent the email to Alice."); - - // ── 7. Assert the Composio backend actually received the action with - // the orchestrator-routed arguments. - let backend_reqs = backend.requests(); - let exec = backend_reqs - .iter() - .find(|(m, p, _)| m == "POST" && p == "/execute") - .expect("Composio /execute must have been called via the orchestrator chain"); - assert_eq!(exec.2["tool"], "GMAIL_SEND_EMAIL"); - assert_eq!(exec.2["arguments"]["recipient_email"], "alice@example.com"); - assert_eq!(exec.2["arguments"]["subject"], "hi"); - - // ── 8. Both sides of the chain should have seen exactly one turn that - // emitted the expected call. - let outer_turns = outer_provider.turns(); - assert!( - outer_turns - .iter() - .any(|t| t.rule_keyword.as_deref() == Some("send an email to alice")), - "orchestrator must have matched the delegation rule" - ); - let inner_turns = inner_provider.turns(); - assert!( - inner_turns - .iter() - .any(|t| t.rule_keyword.as_deref() == Some("toolkit=gmail")), - "integrations agent must have matched its tool-call rule" - ); - - unsafe { - std::env::remove_var("OPENHUMAN_WORKSPACE"); - } -} diff --git a/src/openhuman/agent/harness/tests.rs b/src/openhuman/agent/harness/tests.rs index 004ecabc4..472e5463d 100644 --- a/src/openhuman/agent/harness/tests.rs +++ b/src/openhuman/agent/harness/tests.rs @@ -4,7 +4,6 @@ use super::parse::{ extract_json_values, parse_arguments_value, parse_glm_style_tool_calls, parse_tool_call_value, parse_tool_calls, parse_tool_calls_from_json_value, tools_to_openai_format, }; -use super::tool_loop::{run_tool_call_loop, DEFAULT_MAX_TOOL_ITERATIONS}; use crate::openhuman::inference::provider::traits::ProviderCapabilities; use crate::openhuman::inference::provider::{ChatMessage, ChatRequest, ChatResponse, Provider}; use crate::openhuman::tools::{self, Tool}; @@ -100,130 +99,6 @@ impl Provider for VisionProvider { } } -#[tokio::test] -async fn run_tool_call_loop_returns_structured_error_for_non_vision_provider() { - let calls = Arc::new(AtomicUsize::new(0)); - let provider = NonVisionProvider { - calls: Arc::clone(&calls), - }; - - let mut history = vec![ChatMessage::user( - "please inspect [IMAGE:data:image/png;base64,iVBORw0KGgo=]".to_string(), - )]; - let tools_registry: Vec> = Vec::new(); - - let err = run_tool_call_loop( - &provider, - &mut history, - &tools_registry, - "mock-provider", - "mock-model", - 0.0, - true, - "cli", - &crate::openhuman::config::MultimodalConfig::default(), - &crate::openhuman::config::MultimodalFileConfig::default(), - 3, - None, - None, - &[], - None, - None, - &crate::openhuman::tools::policy::DefaultToolPolicy, - ) - .await - .expect_err("provider without vision support should fail"); - - assert!(err.to_string().contains("provider_capability_error")); - assert!(err.to_string().contains("capability=vision")); - assert_eq!(calls.load(Ordering::SeqCst), 0); -} - -#[tokio::test] -async fn run_tool_call_loop_rejects_oversized_image_payload() { - let calls = Arc::new(AtomicUsize::new(0)); - let provider = VisionProvider { - calls: Arc::clone(&calls), - }; - - let oversized_payload = STANDARD.encode(vec![0_u8; (1024 * 1024) + 1]); - let mut history = vec![ChatMessage::user(format!( - "[IMAGE:data:image/png;base64,{oversized_payload}]" - ))]; - - let tools_registry: Vec> = Vec::new(); - let multimodal = crate::openhuman::config::MultimodalConfig { - max_images: 4, - max_image_size_mb: 1, - allow_remote_fetch: false, - }; - - let err = run_tool_call_loop( - &provider, - &mut history, - &tools_registry, - "mock-provider", - "mock-model", - 0.0, - true, - "cli", - &multimodal, - &crate::openhuman::config::MultimodalFileConfig::default(), - 3, - None, - None, - &[], - None, - None, - &crate::openhuman::tools::policy::DefaultToolPolicy, - ) - .await - .expect_err("oversized payload must fail"); - - assert!(err - .to_string() - .contains("multimodal image size limit exceeded")); - assert_eq!(calls.load(Ordering::SeqCst), 0); -} - -#[tokio::test] -async fn run_tool_call_loop_accepts_valid_multimodal_request_flow() { - let calls = Arc::new(AtomicUsize::new(0)); - let provider = VisionProvider { - calls: Arc::clone(&calls), - }; - - let mut history = vec![ChatMessage::user( - "Analyze this [IMAGE:data:image/png;base64,iVBORw0KGgo=]".to_string(), - )]; - let tools_registry: Vec> = Vec::new(); - - let result = run_tool_call_loop( - &provider, - &mut history, - &tools_registry, - "mock-provider", - "mock-model", - 0.0, - true, - "cli", - &crate::openhuman::config::MultimodalConfig::default(), - &crate::openhuman::config::MultimodalFileConfig::default(), - 3, - None, - None, - &[], - None, - None, - &crate::openhuman::tools::policy::DefaultToolPolicy, - ) - .await - .expect("valid multimodal payload should pass"); - - assert_eq!(result, "vision-ok"); - assert_eq!(calls.load(Ordering::SeqCst), 1); -} - #[test] fn parse_tool_calls_extracts_single_call() { let response = r#"Let me check that. @@ -681,20 +556,6 @@ fn extract_json_values_handles_arrays() { assert_eq!(result.len(), 2); } -// ═══════════════════════════════════════════════════════════════════════ -// Recovery Tests - Constants Validation -// ═══════════════════════════════════════════════════════════════════════ - -const _: () = { - assert!(DEFAULT_MAX_TOOL_ITERATIONS > 0); - assert!(DEFAULT_MAX_TOOL_ITERATIONS <= 100); -}; - -#[test] -fn constants_bounds_are_compile_time_checked() { - // Bounds are enforced by the const assertions above. -} - // ═══════════════════════════════════════════════════════════════════════ // Recovery Tests - Tool Call Value Parsing // ═══════════════════════════════════════════════════════════════════════ diff --git a/src/openhuman/agent/harness/token_budget.rs b/src/openhuman/agent/harness/token_budget.rs deleted file mode 100644 index 6e671f3fd..000000000 --- a/src/openhuman/agent/harness/token_budget.rs +++ /dev/null @@ -1,696 +0,0 @@ -//! Pre-dispatch token budgeting for agent conversation history. -//! -//! Estimates prompt size with the same ~4 chars/token heuristic used elsewhere -//! in the codebase and drops the oldest non-system messages until the payload -//! fits the target model's context window. - -use crate::openhuman::inference::provider::{ChatMessage, ConversationMessage}; - -/// Tokens reserved for the model's completion, tool schemas, and provider overhead. -pub const DEFAULT_OUTPUT_RESERVE_TOKENS: u64 = 8_192; - -/// Minimum reserve when the context window is very small. -const MIN_OUTPUT_RESERVE_TOKENS: u64 = 512; - -/// Outcome of a pre-dispatch trim pass. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct TokenBudgetOutcome { - pub original_tokens: usize, - pub final_tokens: usize, - pub messages_removed: usize, - pub trimmed: bool, -} - -/// `[IMAGE:]` marker prefix. Mirrors -/// [`crate::openhuman::agent::multimodal`]: the harness embeds image -/// attachments as these markers inside the message text until the provider -/// layer promotes them into structured `image_url` parts. Kept local to avoid -/// a dependency on the (private) multimodal constant. -const IMAGE_MARKER_PREFIX: &str = "[IMAGE:"; - -/// Token allowance charged per `[IMAGE:…]` marker instead of counting its -/// base64 `data:` URI as text. -/// -/// **Why this exists (#3205):** an attachment rides as a `[IMAGE:]` -/// marker in the message string, so an 8 MiB image is ~11 M characters. At the -/// `len/4` heuristic that reads as ~2.7 M "tokens" — orders of magnitude past -/// any context window — so the pre-dispatch budget trimmer evicted the whole -/// message *before* the image was ever extracted into `image_url` parts, and -/// the model received a text-only turn (empty/garbage response). Vision models -/// bill an image at a small fixed cost (OpenAI ≈ 85–1100 tokens by detail); we -/// charge a conservative upper bound so the budget stays realistic without the -/// base64 payload ever inflating it. -const IMAGE_MARKER_TOKEN_COST: usize = 1_200; - -/// Rough token estimate: ~4 characters per token (matches tree summarizer). -/// -/// Image markers are charged at a flat [`IMAGE_MARKER_TOKEN_COST`] rather than -/// counting their base64 payload as text — see that constant for the rationale. -/// Markerless text takes the fast path and is unchanged. -pub fn estimate_tokens(text: &str) -> usize { - if !text.contains(IMAGE_MARKER_PREFIX) { - return text.len().saturating_add(3) / 4; - } - - let mut text_bytes = 0usize; - let mut images = 0usize; - let mut cursor = 0usize; - while let Some(rel) = text[cursor..].find(IMAGE_MARKER_PREFIX) { - let start = cursor + rel; - text_bytes += start - cursor; // text preceding the marker - let after = start + IMAGE_MARKER_PREFIX.len(); - match text[after..].find(']') { - Some(rel_end) => { - images += 1; - cursor = after + rel_end + 1; // skip the whole marker payload - } - None => { - // Unterminated marker — count the remainder as text and stop. - text_bytes += text.len() - start; - cursor = text.len(); - break; - } - } - } - text_bytes += text.len() - cursor; // trailing text after the last marker - - (text_bytes.saturating_add(3) / 4) - .saturating_add(images.saturating_mul(IMAGE_MARKER_TOKEN_COST)) -} - -pub fn estimate_chat_message_tokens(msg: &ChatMessage) -> usize { - estimate_tokens(&msg.content) -} - -pub fn estimate_conversation_message_tokens(msg: &ConversationMessage) -> usize { - match msg { - ConversationMessage::Chat(chat) => estimate_chat_message_tokens(chat), - ConversationMessage::AssistantToolCalls { - text, - tool_calls, - reasoning_content, - .. - } => { - let body = text.as_deref().unwrap_or_default(); - let mut total = estimate_tokens(body); - if let Some(reasoning) = reasoning_content.as_deref() { - total = total.saturating_add(estimate_tokens(reasoning)); - } - for call in tool_calls { - total = total.saturating_add(estimate_tokens(&call.name)); - total = total.saturating_add(estimate_tokens(&call.arguments)); - } - total - } - ConversationMessage::ToolResults(results) => results - .iter() - .map(|r| estimate_tokens(&r.tool_call_id).saturating_add(estimate_tokens(&r.content))) - .sum(), - } -} - -fn output_reserve_tokens(context_window: u64) -> u64 { - let pct = context_window / 10; - pct.max(MIN_OUTPUT_RESERVE_TOKENS) - .min(DEFAULT_OUTPUT_RESERVE_TOKENS.max(context_window / 4)) -} - -/// Token budget available for the *input* prompt after reserving room for the -/// model's completion + overhead. Public so the agent engine can detect the -/// un-evictable-prefix overflow (see [`unevictable_prefix_overflow`]). -pub fn max_input_tokens(context_window: u64) -> u64 { - context_window.saturating_sub(output_reserve_tokens(context_window)) -} - -/// Estimated token count of the prompt's **un-evictable prefix** — the -/// messages [`trim_chat_messages_to_budget`] can never drop: every `system` -/// message plus the single newest non-system message (the current user turn). -/// These are exactly the messages a maximal trim leaves behind, so this is the -/// floor below which trimming cannot take the prompt. -fn unevictable_prefix_tokens(messages: &[ChatMessage]) -> usize { - let newest_non_system = messages.iter().rposition(|m| m.role != "system"); - messages - .iter() - .enumerate() - .filter(|(idx, m)| m.role == "system" || Some(*idx) == newest_non_system) - .map(|(_, m)| estimate_chat_message_tokens(m)) - .sum() -} - -/// The actionable, user-facing condition behind Sentry TAURI-RUST-6V0: a local -/// model was loaded with a context window (`context_window`, the runtime -/// `n_ctx`) smaller than the assistant's un-evictable system/prompt prefix -/// (`prefix_tokens`, the runtime `n_keep`). Trimming can evict conversation -/// history but never the system prefix or the current turn, so it can never get -/// the prompt under budget — the request is doomed to the opaque upstream -/// `400 (n_keep >= n_ctx)`. We detect it pre-dispatch and surface the remedy the -/// user actually controls: reload the model with a larger context length. -#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] -#[error( - "Your local model's context length ({context_window} tokens) is smaller than \ - the assistant's required system prompt (~{prefix_tokens} tokens), so the \ - prompt can't be trimmed to fit (n_keep >= n_ctx). Reload the model in your \ - local server (e.g. LM Studio) with a larger context length, then try again." -)] -pub struct ContextPrefixTooLargeError { - /// Tokens in the un-evictable prefix (system + current turn) — the `n_keep`. - pub prefix_tokens: usize, - /// The model's effective (runtime-loaded) context window — the `n_ctx`. - pub context_window: u64, - /// The derived input budget the prefix must fit within. - pub max_input_tokens: u64, -} - -/// Detect the [`ContextPrefixTooLargeError`] condition: the un-evictable prefix -/// alone overflows the model's runtime context window, so even a maximal trim -/// (which can only drop evictable history) can never get the prompt to fit. -/// Returns `Some(err)` only when the prompt can *never* fit — i.e. the prefix is -/// at least as large as the whole `context_window` (`n_keep >= n_ctx`), the exact -/// condition the runtime rejects — so the caller surfaces the actionable error -/// instead of dispatching. `None` when trimming can keep the prompt within -/// budget. -/// -/// The predicate is anchored on `context_window` (the runtime `n_ctx`), **not** -/// `max_input_tokens` (which subtracts a conservative reply reserve). Comparing -/// against `max_input_tokens` would abort valid requests whose prefix fits inside -/// the context window but exceeds the reply reserve — those are over-trim -/// candidates (the reply just gets less room), not the un-fixable -/// `n_keep >= n_ctx` failure this actionable error is meant to report (#3550 / -/// Sentry TAURI-RUST-6V0; CodeRabbit review on PR #3771). -pub fn unevictable_prefix_overflow( - messages: &[ChatMessage], - context_window: u64, -) -> Option { - let max_input = max_input_tokens(context_window); - let prefix_tokens = unevictable_prefix_tokens(messages); - // `n_keep >= n_ctx`: the un-evictable prefix is at least the whole runtime - // window. This is the precise condition the local runtime rejects, and the - // only one no trim can rescue. - (prefix_tokens as u64 >= context_window).then_some(ContextPrefixTooLargeError { - prefix_tokens, - context_window, - max_input_tokens: max_input, - }) -} - -/// Trim `messages` oldest-first (never removing `system` role) until the -/// estimated prompt fits `context_window`. -pub fn trim_chat_messages_to_budget( - messages: &mut Vec, - context_window: u64, -) -> TokenBudgetOutcome { - trim_messages_to_budget( - messages, - context_window, - estimate_chat_message_tokens, - |msg| msg.role == "system", - |msg| msg.role == "tool", - ) -} - -/// Trim conversation `history` oldest-first, preserving system chat messages. -pub fn trim_conversation_history_to_budget( - history: &mut Vec, - context_window: u64, -) -> TokenBudgetOutcome { - trim_messages_to_budget( - history, - context_window, - estimate_conversation_message_tokens, - |msg| matches!(msg, ConversationMessage::Chat(c) if c.role == "system"), - |msg| matches!(msg, ConversationMessage::ToolResults(_)), - ) -} - -fn trim_messages_to_budget( - messages: &mut Vec, - context_window: u64, - estimate: F, - is_system: P, - is_tool_result: R, -) -> TokenBudgetOutcome -where - F: Fn(&T) -> usize, - P: Fn(&T) -> bool, - R: Fn(&T) -> bool, -{ - let max_tokens = max_input_tokens(context_window) as usize; - let original_tokens: usize = messages.iter().map(&estimate).sum(); - - if original_tokens <= max_tokens { - return TokenBudgetOutcome { - original_tokens, - final_tokens: original_tokens, - messages_removed: 0, - trimmed: false, - }; - } - - // Drop oldest non-system messages until the budget fits, preserving the - // original relative order of every retained message (system + non-system). - // Rebuilding as `system ++ other` would reorder history when a system - // message appears after non-system messages, which changes prompt - // semantics (see PR #2100 CodeRabbit review). - let mut removable_positions: Vec = messages - .iter() - .enumerate() - .filter_map(|(idx, msg)| (!is_system(msg)).then_some(idx)) - .collect(); - - let mut removed = 0usize; - while !removable_positions.is_empty() { - let total: usize = messages.iter().map(&estimate).sum(); - if total <= max_tokens { - break; - } - let absolute_idx = removable_positions.remove(0); - // Subsequent positions shift left by one for every prior removal. - let remove_at = absolute_idx - removed; - messages.remove(remove_at); - removed += 1; - } - - // Snap the window forward past any leading orphaned tool results. - // - // Oldest-first eviction removes whole messages from the front, so it can - // drop an `assistant(tool_calls)` while keeping the `tool` result(s) that - // answered it — leaving the window opening on a tool message with no - // preceding `tool_calls`. The provider rejects that with a 400 (`messages - // with role 'tool' must be a response to a preceding message with - // 'tool_calls'`), which streams back empty and surfaces as a generic - // "Something went wrong". Drop leading tool-result messages (covering both - // single and parallel tool cycles) until the first non-system message is a - // clean turn boundary. Mirrors `session::turn::trim_history`'s orphan-snap - // and the summarizer's `snap_split_forward`; the wire-boundary - // `enforce_tool_message_invariants` remains the final repair. - while let Some(first_non_system) = messages.iter().position(|m| !is_system(m)) { - if is_tool_result(&messages[first_non_system]) { - messages.remove(first_non_system); - removed += 1; - } else { - break; - } - } - - let final_tokens: usize = messages.iter().map(&estimate).sum(); - - TokenBudgetOutcome { - original_tokens, - final_tokens, - messages_removed: removed, - trimmed: removed > 0, - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::openhuman::inference::provider::ToolCall; - - fn user_msg(content: &str) -> ChatMessage { - ChatMessage::user(content.to_string()) - } - - #[test] - fn estimate_tokens_charges_flat_cost_per_image_marker_not_base64_length() { - // A ~120k-char base64 data URI inside an `[IMAGE:]` marker must NOT be - // counted as text (that read as ~30k tokens and got the image trimmed, - // #3205). It should cost a flat IMAGE_MARKER_TOKEN_COST plus the small - // surrounding text, regardless of payload size. - let surrounding = "describe this picture"; - let big_uri = format!("data:image/png;base64,{}", "Q".repeat(120_000)); - let with_image = format!("{surrounding} [IMAGE:{big_uri}]"); - - let est = estimate_tokens(&with_image); - let text_only = estimate_tokens(surrounding); - assert_eq!(est, text_only.saturating_add(IMAGE_MARKER_TOKEN_COST)); - assert!( - est < 2_000, - "image marker must not be counted by base64 length (got {est})" - ); - } - - #[test] - fn estimate_tokens_counts_each_image_marker_once() { - let two = "[IMAGE:data:image/png;base64,AAA] and [IMAGE:https://x/y.jpg]"; - let est = estimate_tokens(two); - assert!(est >= 2 * IMAGE_MARKER_TOKEN_COST); - assert!(est < 2 * IMAGE_MARKER_TOKEN_COST + 50); - } - - #[test] - fn estimate_tokens_markerless_text_uses_char_heuristic() { - let text = "x".repeat(400); - assert_eq!(estimate_tokens(&text), 100); - } - - #[test] - fn image_marker_message_is_not_trimmed_within_budget() { - // End-to-end: a huge base64 image in the newest user turn must survive - // the budget trim (it used to be evicted as ~30k tokens). - let big = format!("look [IMAGE:data:image/png;base64,{}]", "Q".repeat(150_000)); - let mut messages = vec![ChatMessage::system("sys"), user_msg(&big)]; - let outcome = trim_chat_messages_to_budget(&mut messages, 200_000); - assert!( - !outcome.trimmed, - "image message must fit the budget, not be trimmed" - ); - assert_eq!(messages.len(), 2); - assert!(messages[1].content.contains("[IMAGE:")); - } - - #[test] - fn under_limit_passes_through_unchanged() { - let mut messages = vec![ - ChatMessage::system("sys"), - user_msg("hello"), - ChatMessage::assistant("hi"), - ]; - let before_len = messages.len(); - let outcome = trim_chat_messages_to_budget(&mut messages, 100_000); - assert!(!outcome.trimmed); - assert_eq!(outcome.original_tokens, outcome.final_tokens); - assert_eq!(messages.len(), before_len); - } - - #[test] - fn over_limit_truncates_oldest_non_system_first() { - let mut messages = vec![ - ChatMessage::system("system prompt"), - user_msg(&"x".repeat(400_000)), - user_msg("keep-me"), - ]; - let outcome = trim_chat_messages_to_budget(&mut messages, 1_000); - assert!(outcome.trimmed); - assert!(outcome.final_tokens < outcome.original_tokens); - assert!(outcome.messages_removed >= 1); - assert_eq!(messages.first().unwrap().role, "system"); - assert!( - messages.iter().any(|m| m.content.contains("keep-me")), - "newest user message should survive trimming" - ); - } - - #[test] - fn conservative_local_floor_bounds_oversized_history() { - // TAURI-RUST-6V0 / #3550: when a local provider resolves a conservative - // fallback window (instead of `None`, which would skip trimming and let - // the prompt overflow the runtime `n_ctx` → a hard 400), trimming MUST - // still engage and leave the prompt BOUNDED — not unbounded. Mirrors the - // 4096-token floor returned by - // `context_window_for_model_with_local_fallback` for a local provider - // with no profile default. - let floor: u64 = 4_096; - let mut messages = vec![ - ChatMessage::system("system prompt"), - user_msg(&"x".repeat(400_000)), // ~100k tokens, far over the floor - user_msg("newest"), - ]; - let outcome = trim_chat_messages_to_budget(&mut messages, floor); - assert!(outcome.trimmed, "oversized history must be trimmed"); - // The retained prompt must fit the input budget derived from the floor — - // i.e. it is bounded, never left at the original ~100k tokens. - let max_input = max_input_tokens(floor) as usize; - assert!( - outcome.final_tokens <= max_input, - "trimmed prompt ({} tokens) must fit the conservative floor budget ({max_input})", - outcome.final_tokens - ); - assert!(outcome.final_tokens < outcome.original_tokens); - // The newest user turn survives. - assert!(messages.iter().any(|m| m.content == "newest")); - } - - #[test] - fn unevictable_prefix_overflow_fires_when_system_prefix_exceeds_local_ctx() { - // TAURI-RUST-6V0: a large system prompt (the un-evictable `n_keep`) - // alone exceeds a small local context window (`n_ctx`). Trimming can - // never help — it never drops the system message — so the pre-dispatch - // guard must fire with the numbers needed for the actionable error. - let ctx: u64 = 8_192; // LM Studio loaded n_ctx from the live events - let mut messages = vec![ - // ~11k-token system prefix (the reported n_keep ≈ 10978). - ChatMessage::system(&"s".repeat(44_000)), - user_msg("hi"), - ]; - // Even a maximal trim leaves the prompt over budget. - let outcome = trim_chat_messages_to_budget(&mut messages, ctx); - assert!( - outcome.final_tokens as u64 > max_input_tokens(ctx), - "precondition: trim cannot fit the prompt" - ); - - let overflow = unevictable_prefix_overflow(&messages, ctx) - .expect("un-evictable prefix overflow must be detected"); - assert_eq!(overflow.context_window, ctx); - assert!( - overflow.prefix_tokens as u64 > overflow.max_input_tokens, - "prefix must exceed the input budget" - ); - // The user-facing remedy + the diagnostic anchor are both present. - let msg = overflow.to_string(); - assert!( - msg.contains("larger context length"), - "must name the remedy" - ); - assert!( - msg.contains("n_keep >= n_ctx"), - "must carry the diagnostic anchor" - ); - } - - #[test] - fn unevictable_prefix_overflow_anchors_on_context_window_not_reply_reserve() { - // CodeRabbit boundary case (PR #3771): a prefix that lands in the band - // `max_input_tokens < prefix < context_window` fits the runtime `n_ctx` - // — the reply just gets less room — so it MUST NOT be aborted as the - // un-fixable `n_keep >= n_ctx` failure. Anchoring on `max_input_tokens` - // (the old `>` predicate) would have wrongly aborted it. - let ctx: u64 = 8_192; - // output_reserve = max(819, min(8192/4=2048, 8192/10=819)) = 819, so - // max_input = 8192 - 819 = 7373. - let max_input = max_input_tokens(ctx); - // ~7700-token system prefix: above max_input (7373), below ctx (8192). - let messages = vec![ - ChatMessage::system(&"s".repeat(30_800)), // (30800+3)/4 = 7700 tokens - user_msg("hi"), - ]; - let prefix = unevictable_prefix_tokens(&messages) as u64; - assert!( - prefix > max_input && prefix < ctx, - "test precondition: prefix ({prefix}) must sit between max_input \ - ({max_input}) and context_window ({ctx})" - ); - assert!( - unevictable_prefix_overflow(&messages, ctx).is_none(), - "prefix that fits the context window (just over the reply reserve) \ - must NOT be aborted — it is an over-trim case, not n_keep >= n_ctx" - ); - - // The same prefix against a context window it actually overflows - // (prefix >= n_ctx) MUST fire. - let small_ctx: u64 = prefix; // n_ctx == n_keep ⇒ the boundary itself fires - assert!( - unevictable_prefix_overflow(&messages, small_ctx).is_some(), - "prefix >= context_window (n_keep >= n_ctx) must be detected" - ); - } - - #[test] - fn unevictable_prefix_overflow_none_when_prompt_fits() { - // A small prompt against a normal window: trimming isn't needed and the - // guard must NOT fire (no false-positive actionable error). - let messages = vec![ChatMessage::system("short system"), user_msg("hello")]; - assert!(unevictable_prefix_overflow(&messages, 8_192).is_none()); - } - - #[test] - fn unevictable_prefix_overflow_counts_newest_turn_not_old_history() { - // The un-evictable prefix is system + the NEWEST non-system turn. Old - // history is evictable, so a huge oldest turn must NOT, by itself, - // trigger the guard when the system prefix + newest turn fit. - let ctx: u64 = 32_000; - let messages = vec![ - ChatMessage::system("small system"), - user_msg(&"x".repeat(400_000)), // oldest, evictable — ~100k tokens - user_msg("newest small turn"), - ]; - assert!( - unevictable_prefix_overflow(&messages, ctx).is_none(), - "evictable old history must not count toward the un-evictable prefix" - ); - } - - #[test] - fn trim_conversation_history_drops_oldest_messages() { - let mut messages = vec![ConversationMessage::Chat(user_msg(&"y".repeat(80_000)))]; - let outcome = trim_conversation_history_to_budget(&mut messages, 1_000); - assert!(outcome.trimmed); - assert!(outcome.original_tokens > outcome.final_tokens); - } - - #[test] - fn conversation_tool_results_are_counted_in_estimate() { - let msg = ConversationMessage::ToolResults(vec![ - crate::openhuman::inference::provider::ToolResultMessage { - tool_call_id: "c1".into(), - content: "z".repeat(8_000), - }, - ]); - assert!(estimate_conversation_message_tokens(&msg) > 1_000); - } - - #[test] - fn trim_preserves_relative_order_when_system_appears_late() { - // System message in the middle of history must not be moved to the - // front during trimming. Regression guard for PR #2100 review. - let mut messages = vec![ - user_msg(&"a".repeat(40_000)), // oldest non-system, expected to drop - user_msg("first-user"), - ChatMessage::system("late-system"), - user_msg("last-user"), - ]; - let outcome = trim_chat_messages_to_budget(&mut messages, 1_000); - assert!(outcome.trimmed); - // System position relative to surrounding messages is preserved. - let roles: Vec<&str> = messages.iter().map(|m| m.role.as_str()).collect(); - let sys_idx = roles - .iter() - .position(|r| *r == "system") - .expect("system message must be retained"); - // At least one user message should still precede the late system message. - assert!( - sys_idx > 0, - "late system message must remain after earlier surviving non-system messages" - ); - assert!( - messages.iter().any(|m| m.content == "last-user"), - "newest user message must survive" - ); - } - - #[test] - fn assistant_tool_calls_estimate_includes_arguments() { - let msg = ConversationMessage::AssistantToolCalls { - text: Some("thinking".into()), - tool_calls: vec![ToolCall { - id: "1".into(), - name: "echo".into(), - arguments: "{\"value\":\"x\"}".into(), - extra_content: None, - }], - reasoning_content: None, - extra_metadata: None, - }; - assert!(estimate_conversation_message_tokens(&msg) > 0); - } - - fn tool_results(ids: &[&str]) -> ConversationMessage { - ConversationMessage::ToolResults( - ids.iter() - .map( - |id| crate::openhuman::inference::provider::ToolResultMessage { - tool_call_id: (*id).into(), - content: format!("result-{id}"), - }, - ) - .collect(), - ) - } - - fn tool_call(id: &str) -> ToolCall { - ToolCall { - id: id.into(), - name: "f".into(), - arguments: "{}".into(), - extra_content: None, - } - } - - #[test] - fn chat_budget_trim_snaps_past_orphaned_tool_result() { - // Oldest-first eviction drops the assistant turn and would leave the - // `tool` result as a leading orphan (provider 400). The snap must drop - // it so the window opens on a clean turn boundary. - let mut messages = vec![ - ChatMessage::system("sys"), - ChatMessage::assistant(&"a".repeat(400_000)), // oldest non-system → evicted - ChatMessage::tool("result for the evicted tool call"), - user_msg("keep-me"), - ]; - let outcome = trim_chat_messages_to_budget(&mut messages, 1_000); - assert!(outcome.trimmed); - let first_non_system = messages - .iter() - .find(|m| m.role != "system") - .expect("a non-system message survives"); - assert_ne!( - first_non_system.role, "tool", - "leading orphan tool result must be snapped away, not sent to the provider" - ); - assert!(messages.iter().any(|m| m.content == "keep-me")); - } - - #[test] - fn conversation_budget_trim_snaps_past_orphaned_parallel_tool_results() { - // A parallel cycle: one AssistantToolCalls answered by two ToolResults. - // Evicting the call must not leave either result orphaned at the head. - let mut history = vec![ - ConversationMessage::Chat(ChatMessage::system("sys")), - ConversationMessage::AssistantToolCalls { - text: Some("x".repeat(400_000)), // oldest non-system → evicted - tool_calls: vec![tool_call("X"), tool_call("Y")], - reasoning_content: None, - extra_metadata: None, - }, - tool_results(&["X"]), - tool_results(&["Y"]), - ConversationMessage::Chat(user_msg("keep-me")), - ]; - let outcome = trim_conversation_history_to_budget(&mut history, 1_000); - assert!(outcome.trimmed); - let first_non_system = history - .iter() - .find(|m| !matches!(m, ConversationMessage::Chat(c) if c.role == "system")) - .expect("a non-system message survives"); - assert!( - !matches!(first_non_system, ConversationMessage::ToolResults(_)), - "leading orphan tool results must be snapped away" - ); - assert!(history - .iter() - .any(|m| matches!(m, ConversationMessage::Chat(c) if c.content == "keep-me"))); - } - - #[test] - fn conversation_budget_trim_keeps_paired_call_and_result_at_window_head() { - // When the post-trim window opens on an assistant(tool_calls) followed - // by its result, the snap must NOT remove the (validly paired) result. - let mut history = vec![ - ConversationMessage::Chat(user_msg(&"x".repeat(400_000))), // evicted - ConversationMessage::AssistantToolCalls { - text: None, - tool_calls: vec![tool_call("A")], - reasoning_content: None, - extra_metadata: None, - }, - tool_results(&["A"]), - ConversationMessage::Chat(user_msg("keep")), - ]; - let outcome = trim_conversation_history_to_budget(&mut history, 1_000); - assert!(outcome.trimmed); - assert!( - matches!( - history.first(), - Some(ConversationMessage::AssistantToolCalls { .. }) - ), - "window should open on the surviving tool call" - ); - assert!( - history - .iter() - .any(|m| matches!(m, ConversationMessage::ToolResults(_))), - "a validly-paired tool result must be retained, not over-snapped" - ); - } -} diff --git a/src/openhuman/agent/harness/tool_loop.rs b/src/openhuman/agent/harness/tool_loop.rs deleted file mode 100644 index ba547a002..000000000 --- a/src/openhuman/agent/harness/tool_loop.rs +++ /dev/null @@ -1,709 +0,0 @@ -use crate::openhuman::agent::progress::AgentProgress; -use crate::openhuman::inference::provider::{ChatMessage, Provider, AGENT_TURN_MAX_OUTPUT_TOKENS}; -use crate::openhuman::tools::policy::{DefaultToolPolicy, ToolPolicy}; -use crate::openhuman::tools::Tool; -use anyhow::Result; -use std::collections::HashSet; - -use super::payload_summarizer::PayloadSummarizer; - -/// Minimum characters per chunk when relaying LLM text to a streaming draft. -pub(crate) const STREAM_CHUNK_MIN_CHARS: usize = 80; - -/// Default maximum agentic tool-use iterations per user message to prevent runaway loops. -/// Used as a safe fallback when `max_tool_iterations` is unset or configured as zero. -pub(crate) const DEFAULT_MAX_TOOL_ITERATIONS: usize = 10; - -/// Extended iteration cap for agents with `IterationPolicy::Extended`. These -/// are multi-step specialists (code executor, integrations, planner, …) whose -/// realistic workflows commonly exceed the default 10-iteration cap. The -/// repeated-failure circuit breaker and cost budget remain the primary runaway -/// guards; this value is intentionally generous to avoid premature stops. -pub(crate) const EXTENDED_MAX_TOOL_ITERATIONS: usize = 50; - -/// Repeated-failure circuit breaker. The plain iteration cap lets an agent grind -/// the same dead-end (e.g. re-running `pip install` when there is no pip) until -/// `max_iterations`, then return an opaque `MaxIterationsExceeded` that the caller -/// just re-spawns — losing the failure context. These thresholds let the loop bail -/// EARLY with a root-cause summary instead. -/// -/// If the SAME `(tool, args)` call fails this many times, the agent is repeating a -/// known-failed action verbatim — stop. -pub(crate) const REPEAT_FAILURE_THRESHOLD: u32 = 3; -/// Recoverable/transient failures (timeouts, connection resets, rate limits, ...) -/// are still bounded, but need more room than deterministic terminal failures so -/// the model can adapt (change timeout, narrow work, split a command, retry a -/// flaky network call) before the breaker stops the turn. -pub(crate) const RECOVERABLE_REPEAT_FAILURE_THRESHOLD: u32 = 8; -/// If this many non-recoverable tool calls fail back-to-back with no success in -/// between (even with varied args), the agent is making no progress — stop. -pub(crate) const NO_PROGRESS_FAILURE_THRESHOLD: u32 = 6; -/// Recoverable failures get a separate, larger no-progress headroom. The -/// iteration cap and cost budget still bound the turn, while a handful of -/// timeouts no longer stops an otherwise adaptable agent. -pub(crate) const RECOVERABLE_NO_PROGRESS_FAILURE_THRESHOLD: u32 = 12; -/// Hard policy rejections (a security block or a gate denial) are deterministic: -/// the identical `(tool, args)` call provably cannot succeed. Halt on the FIRST -/// verbatim repeat — i.e. the second identical attempt — rather than letting the -/// agent burn the generic [`REPEAT_FAILURE_THRESHOLD`] on a doomed call. The first -/// occurrence is allowed through so the model can read the "do not retry" reason -/// and pivot to a different, allowed approach. -pub(crate) const HARD_REJECT_REPEAT_THRESHOLD: u32 = 2; - -/// Classification of a deterministic, recognizable policy rejection, detected via -/// the stable markers the security/approval layers emit -/// ([`crate::openhuman::security::POLICY_BLOCKED_MARKER`] / -/// [`POLICY_DENIED_MARKER`](crate::openhuman::security::POLICY_DENIED_MARKER)). -#[derive(Clone, Copy, PartialEq, Eq, Debug)] -pub(crate) enum HardReject { - /// Permanent for this tier (read-only write, forbidden/credential path, - /// disallowed command) — never succeeds on retry. - Blocked, - /// User denied / approval timed out this turn — re-asking the identical call - /// only re-prompts. - Denied, -} - -/// Recognize a hard policy rejection from a tool result. Matches anywhere in the -/// string (not just the prefix) so it survives the `Error: …` wrapping the tool -/// layer adds. `Blocked` takes precedence over `Denied` if both somehow appear. -pub(crate) fn hard_reject_kind(result: &str) -> Option { - if result.contains(crate::openhuman::security::POLICY_BLOCKED_MARKER) { - Some(HardReject::Blocked) - } else if result.contains(crate::openhuman::security::POLICY_DENIED_MARKER) { - Some(HardReject::Denied) - } else { - None - } -} - -/// A permanent, non-retryable inference failure surfaced by a tool result — -/// typically a delegated sub-agent (`run_code` / `tools_agent` / `plan`) whose -/// provider call hit a user-state wall. Unlike a transient error, re-issuing the -/// call cannot succeed even under a *different* delegation tool or varied args: -/// the budget is account-wide and the model/provider configuration is shared by -/// every sub-agent. See [`terminal_inference_failure_kind`]. -#[derive(Clone, Copy, PartialEq, Eq, Debug)] -pub(crate) enum TerminalInferenceFailure { - /// Out of inference budget / credits — every retry hits the same wall. - /// Detected via - /// [`is_budget_exhausted_message`](crate::openhuman::inference::provider::is_budget_exhausted_message). - BudgetExhausted, - /// The configured model/provider rejected the request for a reason the user - /// must fix (unknown model, non-chat/embedding model, missing credential, - /// region block, …). Detected via - /// [`is_provider_config_rejection_message`](crate::openhuman::inference::provider::is_provider_config_rejection_message). - ProviderConfig, -} - -/// Inference/delegation **envelope** markers that prove a tool result came from -/// a delegated inference call (a sub-agent / provider round-trip) rather than -/// from arbitrary tool stderr. -/// -/// The two provider classifiers ([`is_budget_exhausted_message`] / -/// [`is_provider_config_rejection_message`]) match on short message substrings -/// (`"insufficient balance"`, `"invalid temperature"`, `"model field is -/// required"`, …) that can legitimately appear in a *recoverable* tool's output -/// — e.g. a `shell`/`run_code` script printing `ValueError: invalid temperature` -/// or a test asserting on `"model field is required"`. Applying the terminal -/// halt to those would misreport a fixable script failure as "fix the model or -/// API key" and stop after a single attempt. -/// -/// Gating on these envelope markers scopes the classifier to genuinely -/// delegated inference failures. Every marker here is **harness-generated** — -/// produced by our own reliable-chain rollup or sub-agent dispatch wrapper, NOT -/// by a provider HTTP body that arbitrary tool stderr could forge: -/// * the reliable-chain exhaustion rollup (`"All providers/models failed"` / -/// `"may not be available on your provider"`, reliable.rs), and -/// * the sub-agent dispatch wrapper (`"failed and did not complete"`, see -/// [`crate::openhuman::agent_orchestration::tools::dispatch::format_subagent_failure`]). -/// -/// **Why the bare provider envelope is NOT a marker (Codex review #3779):** the -/// raw provider-HTTP shape (`" API error (…)"`, `" Responses -/// API error: …"`) is reproducible verbatim by a *recoverable* tool that is -/// debugging its own API client — e.g. a `shell`/`run_code` script printing -/// `OpenAI API error (400): invalid temperature` or `… model field is required`. -/// Matching the bare `"api error"` substring there would let that script trip -/// the broad provider-config classifier and HALT the whole turn after a single -/// failed command with a misleading "fix your model in Settings → AI" message, -/// even though the agent should just recover. Every *genuine* delegated -/// inference failure additionally surfaces through one of the harness wrappers -/// above (a sub-agent provider error reaches the orchestrator only via -/// `dispatch::format_subagent_failure`; a direct reliable-chain exhaustion via -/// the rollup), so dropping the bare provider envelope loses no real detection -/// while closing the false-positive on tool stderr. -/// -/// [`is_budget_exhausted_message`]: crate::openhuman::inference::provider::is_budget_exhausted_message -/// [`is_provider_config_rejection_message`]: crate::openhuman::inference::provider::is_provider_config_rejection_message -const INFERENCE_FAILURE_ENVELOPE_MARKERS: &[&str] = &[ - // Reliable-chain exhaustion rollup (reliable.rs::format_failure_aggregate). - "all providers/models failed", - "may not be available on your provider", - // Sub-agent delegation failure wrapper (dispatch.rs::format_subagent_failure). - "failed and did not complete", -]; - -/// True if `result` carries one of the inference/delegation envelope markers — -/// i.e. the failure demonstrably came from a delegated provider round-trip, not -/// from an arbitrary tool's stderr. See [`INFERENCE_FAILURE_ENVELOPE_MARKERS`]. -fn has_inference_failure_envelope(result: &str) -> bool { - let lower = result.to_ascii_lowercase(); - INFERENCE_FAILURE_ENVELOPE_MARKERS - .iter() - .any(|marker| lower.contains(marker)) -} - -/// Recognize a permanent (non-retryable) inference failure from a tool result. -/// -/// Two-stage gate so a *recoverable* tool failure can't be misclassified: -/// 1. The result must carry a delegated-inference **envelope** -/// ([`has_inference_failure_envelope`]) — proving it came from a sub-agent -/// / provider round-trip and not from arbitrary tool stderr that merely -/// happens to contain a classifier substring (e.g. a `shell` script -/// printing `ValueError: invalid temperature` or a test asserting on -/// `"model field is required"`). Without this guard a fixable script/test -/// failure would be misreported as "fix the model or API key" and stopped -/// after a single attempt (Codex review #3779). -/// 2. The (then-trusted) body is matched against the two deliberately-tight -/// provider classifiers, which stay in lockstep with the Sentry-demotion -/// phrase sets: a transient / 5xx / generic 4xx body matches NEITHER, so -/// genuinely retryable failures still get the normal consecutive-failure -/// grace ([`NO_PROGRESS_FAILURE_THRESHOLD`]) and are never halted early. -/// Budget takes precedence if both somehow match. -/// -/// The orchestrator otherwise re-emits a failed delegation under *varied* tool -/// names (Plan → `run_code` → `tools_agent`), so the identical-`(tool,args)` -/// [`REPEAT_FAILURE_THRESHOLD`] never trips and the chain grinds through ~6-8 -/// doomed, paid delegations before [`NO_PROGRESS_FAILURE_THRESHOLD`] finally -/// halts with an opaque "Something went wrong" (#3104). Tripping on the FIRST -/// permanent failure stops that cascade and surfaces the root cause. -pub(crate) fn terminal_inference_failure_kind(result: &str) -> Option { - use crate::openhuman::inference::provider::{ - is_budget_exhausted_message, is_provider_config_rejection_message, - }; - // Require the delegated-inference envelope first: the message-only - // classifiers are too broad to apply to arbitrary tool stderr. - if !has_inference_failure_envelope(result) { - return None; - } - if is_budget_exhausted_message(result) { - Some(TerminalInferenceFailure::BudgetExhausted) - } else if is_provider_config_rejection_message(result) { - Some(TerminalInferenceFailure::ProviderConfig) - } else { - None - } -} - -/// Failures that are informative and plausibly recoverable by changing the next -/// action (longer timeout, smaller batch, different network retry/fallback) -/// rather than by immediately abandoning the turn. -/// -/// Keep this deliberately marker-based and conservative: it only controls -/// breaker headroom, never converts a failure into success. Hard policy rejects -/// and permanent provider/account failures are classified before this function. -pub(crate) fn is_recoverable_tool_failure(result: &str) -> bool { - let lower = result.to_ascii_lowercase(); - [ - "timed out", - "timeout", - "deadline exceeded", - "temporarily unavailable", - "temporary failure", - "connection reset", - "connection refused", - "connection closed", - "connection aborted", - "network is unreachable", - "host is unreachable", - "dns error", - "failed to lookup address", - "failed to resolve", - "rate limit", - "too many requests", - "retry after", - "503 service unavailable", - "502 bad gateway", - "504 gateway timeout", - ] - .iter() - .any(|marker| lower.contains(marker)) -} - -/// Shared repeated-failure circuit breaker, used by BOTH agent loops -/// (`run_tool_call_loop` here and `run_inner_loop` in `subagent_runner`) so they -/// can't drift. Tracks per-`(tool,args)`-signature failure counts and a -/// consecutive-failure run within a single agent turn; [`Self::record`] returns -/// a root-cause halt summary once a threshold trips. -#[derive(Default)] -pub(crate) struct RepeatFailureGuard { - sig_counts: std::collections::HashMap, - consecutive: u32, - consecutive_recoverable: u32, -} - -impl RepeatFailureGuard { - pub(crate) fn new() -> Self { - Self::default() - } - - /// Record one tool-call outcome. `args_sig` is a stable string form of the - /// arguments (e.g. the command). Returns `Some(summary)` when the breaker - /// trips — the caller should stop the loop and return that summary as the - /// agent's result instead of grinding to `max_iterations`. - pub(crate) fn record( - &mut self, - tool: &str, - args_sig: &str, - success: bool, - result: &str, - ) -> Option { - if success { - self.consecutive = 0; - self.consecutive_recoverable = 0; - return None; - } - let count = { - let c = self - .sig_counts - .entry(format!("{tool}|{args_sig}")) - .or_insert(0); - *c += 1; - *c - }; - // Permanent inference failures (out of budget / provider-config rejection) - // cannot be recovered by retrying — the budget is account-wide and the - // model/provider config is shared by every (sub-)agent. Halt on the FIRST - // occurrence with an actionable root cause instead of letting the - // orchestrator re-emit the step under varied delegation-tool names until - // NO_PROGRESS_FAILURE_THRESHOLD (#3104: Plan → Run Code ×6 → Tools Agent - // ×2). Checked before the count-based thresholds precisely because those - // never trip in time when the tool name keeps changing. - if let Some(kind) = terminal_inference_failure_kind(result) { - tracing::warn!( - tool, - kind = ?kind, - "[agent_loop] permanent inference failure — halting on first occurrence with root cause" - ); - return Some(match kind { - TerminalInferenceFailure::BudgetExhausted => format!( - "Stopping: the `{tool}` step failed because the account is out of inference \ - budget/credits — every retry hits the same wall. Add credits to your account \ - (or, when using a custom/BYO provider, top up that provider's own account) \ - and try again. Details:\n{}", - truncate_for_halt(result), - ), - TerminalInferenceFailure::ProviderConfig => format!( - "Stopping: the `{tool}` step failed because the configured model/provider \ - rejected the request (e.g. an unknown model, a non-chat/embedding model, a \ - missing credential, or a region block) — retrying will not help. Fix the model \ - or API key in Settings → AI. Details:\n{}", - truncate_for_halt(result), - ), - }); - } - // Hard policy rejections trip on the first verbatim repeat; recoverable - // failures get extra headroom; everything else uses the generic - // identical-retry threshold. - let hard = hard_reject_kind(result); - let recoverable = hard.is_none() && is_recoverable_tool_failure(result); - if recoverable { - self.consecutive_recoverable += 1; - tracing::debug!( - tool, - count, - consecutive_recoverable = self.consecutive_recoverable, - "[agent_loop] recoverable tool failure recorded with extended circuit-breaker headroom" - ); - } else { - self.consecutive += 1; - self.consecutive_recoverable = 0; - } - let repeat_threshold = if hard.is_some() { - HARD_REJECT_REPEAT_THRESHOLD - } else if recoverable { - RECOVERABLE_REPEAT_FAILURE_THRESHOLD - } else { - REPEAT_FAILURE_THRESHOLD - }; - if count >= repeat_threshold { - return Some(match hard { - Some(HardReject::Blocked) => format!( - "Stopping: the `{tool}` call is blocked by the security policy and was \ - re-issued with identical arguments — it can never succeed this way. \ - Reason:\n{}\n\nDo not repeat this call; use an allowed alternative or report \ - that it can't be done here.", - truncate_for_halt(result), - ), - Some(HardReject::Denied) => format!( - "Stopping: the `{tool}` call was denied and re-issued unchanged — re-asking \ - will not change the answer. Reason:\n{}\n\nDo not repeat this call; take a \ - different approach or report that it can't be done here.", - truncate_for_halt(result), - ), - None => format!( - "Stopping: the `{tool}` call was retried {count} times with identical \ - arguments and kept failing — repeating it will not help. Last error:\n{}\n\n\ - {} Report this back instead of retrying.", - truncate_for_halt(result), - if recoverable { - "This looked recoverable at first, but the same call exhausted the \ - extended transient-failure headroom." - } else { - "This looks unrecoverable in the current environment (e.g. a missing \ - tool/dependency that cannot be installed here)." - }, - ), - }); - } - if recoverable { - if self.consecutive_recoverable >= RECOVERABLE_NO_PROGRESS_FAILURE_THRESHOLD { - return Some(format!( - "Stopping: {} recoverable-looking tool failures happened in a row with no \ - successful progress. Last error (from `{tool}`):\n{}\n\nThe turn is still \ - bounded by the iteration/cost limits, but this many consecutive transient \ - failures means the goal is not currently reachable. Report this back instead \ - of retrying.", - self.consecutive_recoverable, - truncate_for_halt(result), - )); - } - return None; - } - if self.consecutive >= NO_PROGRESS_FAILURE_THRESHOLD { - return Some(format!( - "Stopping: {} tool calls in a row failed with no progress. Last error (from \ - `{tool}`):\n{}\n\nDifferent commands are all failing — the goal looks unreachable \ - in this environment. Report this back instead of retrying.", - self.consecutive, - truncate_for_halt(result), - )); - } - None - } -} - -/// If the model emits the IDENTICAL assistant output (narrative text + the same -/// tool-call name/args) this many times in a row, it's stuck in a no-progress -/// narration loop — halt. Set low enough to bail early (the observed -/// degeneration repeated ~195×) but above any legitimate short retry. -pub(crate) const REPEAT_OUTPUT_THRESHOLD: u32 = 4; - -/// Repeat-OUTPUT circuit breaker — distinct from [`RepeatFailureGuard`], which -/// only counts tool *failures* and resets on every success. -/// -/// This catches the degenerate case where each iteration re-emits the SAME -/// narration + SAME tool call and the call nominally "succeeds" yet nothing -/// advances (e.g. the model narrating "now let me create the files…" and -/// re-issuing the same `run_code` forever). That loop is invisible to two -/// things people reach for first: -/// * `frequency_penalty` — per-generation only; each iteration is a fresh, -/// individually non-repetitive generation, so it has nothing to penalise -/// and no memory across turns. -/// * [`RepeatFailureGuard`] — resets on success, so a repeated *successful* -/// no-op never trips it. -/// -/// Trips on `REPEAT_OUTPUT_THRESHOLD` consecutive identical signatures; a -/// different signature (real progress) resets the run, so interleaved varied -/// work never trips it. -#[derive(Default)] -pub(crate) struct RepeatOutputGuard { - last_hash: Option, - consecutive: u32, -} - -impl RepeatOutputGuard { - pub(crate) fn new() -> Self { - Self::default() - } - - /// Reset the streak — used when an iteration is a legitimately-repeating - /// poll/wait (see [`is_repeat_call_exempt`]) that should count as a distinct - /// action rather than a no-progress repeat. - pub(crate) fn reset(&mut self) { - self.last_hash = None; - self.consecutive = 0; - } - - /// Record one iteration's output signature (assistant text + tool-call - /// name/args). Returns `Some(halt summary)` once the identical signature has - /// repeated [`REPEAT_OUTPUT_THRESHOLD`] times back-to-back. - pub(crate) fn record(&mut self, signature: &str) -> Option { - use std::hash::{Hash, Hasher}; - let mut hasher = std::collections::hash_map::DefaultHasher::new(); - signature.hash(&mut hasher); - let h = hasher.finish(); - if self.last_hash == Some(h) { - self.consecutive += 1; - } else { - self.last_hash = Some(h); - self.consecutive = 1; - } - if self.consecutive >= REPEAT_OUTPUT_THRESHOLD { - return Some(format!( - "Stopping: the last {} iterations produced the IDENTICAL response and tool call \ - with no change — the run is stuck repeating the same step without making \ - progress. Re-issuing it will not help. Summarise what (if anything) was actually \ - accomplished and report that the task could not progress, or take a genuinely \ - different approach.", - self.consecutive, - )); - } - None - } -} - -/// If the model emits the IDENTICAL set of tool calls (the same `(tool, args)` -/// batch) this many times in a row — regardless of whether each call -/// *succeeds* — it is spinning the same action with no new information. Set just -/// below [`REPEAT_OUTPUT_THRESHOLD`] so a verbatim call loop is caught a step -/// earlier than the broader narration+call loop, and low enough to bail before -/// the iteration cap burns the whole budget (#4088). -pub(crate) const REPEAT_CALL_THRESHOLD: u32 = 3; - -/// Tools whose contract is to be re-invoked with identical arguments, so an -/// identical repeat is legitimate progress — not a no-progress loop. Today this -/// is `wait_subagent`, which polls a running async sub-agent and explicitly -/// tells the model to "call wait_subagent again" when a `timeout_secs` window -/// elapses while the sub-agent is still running. Without this exemption a task -/// that outlives two wait windows would have its third identical -/// `wait_subagent({task_id})` halted by the no-progress breakers before it could -/// collect the eventual result, and the parent would misreport a stuck turn -/// (Codex P1 on #4230). The iteration cap + cost budget still bound the wait. -pub(crate) fn is_repeat_call_exempt(tool: &str) -> bool { - matches!(tool, "wait_subagent") -} - -/// Repeated-CALL circuit breaker — closes the gap between [`RepeatFailureGuard`] -/// (resets on every success, so a repeated *successful* no-op never trips it) -/// and [`RepeatOutputGuard`] (keys on the assistant narration TOO, so trivially -/// varied prose around the same call resets the streak). -/// -/// This guard keys ONLY on the canonical `(tool, args)` batch — no narration, -/// independent of success — so `list_dir("/app")` issued verbatim N times in a -/// row trips it even when each call returns 200 and the model reworded its -/// reasoning each time. A genuinely different call (different path / query / id) -/// changes the signature and resets the streak, so real progress — including the -/// read → write → read pattern, where the write is a distinct intervening call — -/// never trips it. -/// -/// The caller builds the signature from `serde_json::Value::to_string()` of each -/// call's arguments, which is key-sorted and whitespace-free in this tree (no -/// `preserve_order` feature), so the SAME call expressed with reordered JSON keys -/// still collapses to one signature and cannot evade the guard. -#[derive(Default)] -pub(crate) struct RepeatCallGuard { - last_hash: Option, - consecutive: u32, -} - -impl RepeatCallGuard { - pub(crate) fn new() -> Self { - Self::default() - } - - /// Reset the streak — used when an iteration is a legitimately-repeating - /// poll/wait (see [`is_repeat_call_exempt`]) that should count as a distinct - /// action rather than a no-progress repeat. - pub(crate) fn reset(&mut self) { - self.last_hash = None; - self.consecutive = 0; - } - - /// Record one iteration's tool-call signature (the canonical `(tool, args)` - /// of every call in the assistant message, in order). Returns `Some(halt - /// summary)` once the identical batch has repeated [`REPEAT_CALL_THRESHOLD`] - /// times back-to-back; a different signature (real progress) resets the run. - pub(crate) fn record(&mut self, signature: &str) -> Option { - use std::hash::{Hash, Hasher}; - let mut hasher = std::collections::hash_map::DefaultHasher::new(); - signature.hash(&mut hasher); - let h = hasher.finish(); - if self.last_hash == Some(h) { - self.consecutive += 1; - } else { - self.last_hash = Some(h); - self.consecutive = 1; - } - if self.consecutive >= REPEAT_CALL_THRESHOLD { - return Some(format!( - "Stopping: the same tool call was issued {} times in a row with identical \ - arguments and no new information — the run is stuck repeating one action \ - without making progress. Re-issuing it will not help. Summarise what (if \ - anything) was actually accomplished and report that the task could not \ - progress, or take a genuinely different action (a different tool, different \ - arguments, or hand back).", - self.consecutive, - )); - } - None - } -} - -/// Clamp the last-error text embedded in a circuit-breaker halt summary so a huge -/// tool error (already capped at 1MB upstream) can't blow up the agent's result. -pub(crate) fn truncate_for_halt(s: &str) -> String { - const MAX: usize = 600; - if s.chars().count() <= MAX { - return s.to_string(); - } - let head: String = s.chars().take(MAX).collect(); - format!("{head}\n… [truncated]") -} - -/// Execute a single turn of the agent loop: send messages, parse tool calls, -/// execute tools, and loop until the LLM produces a final text response. -/// When `silent` is true, suppresses stdout (for channel use). -/// -/// This is a thin wrapper around [`run_tool_call_loop`] with the per-agent -/// filter and extra-tool plumbing disabled — i.e. the LLM sees the entire -/// `tools_registry` unchanged. Used by legacy call sites and harness tests -/// that don't need agent-aware scoping. -#[allow(clippy::too_many_arguments)] -pub(crate) async fn agent_turn( - provider: &dyn Provider, - history: &mut Vec, - tools_registry: &[Box], - provider_name: &str, - model: &str, - temperature: f64, - silent: bool, - multimodal_config: &crate::openhuman::config::MultimodalConfig, - multimodal_file_config: &crate::openhuman::config::MultimodalFileConfig, - max_tool_iterations: usize, - payload_summarizer: Option<&dyn PayloadSummarizer>, -) -> Result { - let default_policy = DefaultToolPolicy; - run_tool_call_loop( - provider, - history, - tools_registry, - provider_name, - model, - temperature, - silent, - "channel", - multimodal_config, - multimodal_file_config, - max_tool_iterations, - None, - None, - &[], - None, - payload_summarizer, - &default_policy, - ) - .await -} - -/// Execute a single turn of the agent loop: send messages, parse tool calls, -/// execute tools, and loop until the LLM produces a final text response. -/// -/// # Per-agent tool scoping -/// -/// The last two parameters support per-agent tool filtering without -/// requiring callers to build a filtered copy of the (non-`Clone`able) -/// tool registry: -/// -/// * `visible_tool_names` — optional whitelist of tool names that are -/// allowed to reach the LLM. When `Some(set)`, only tools whose -/// `name()` is present in the set contribute to the function-calling -/// schema and are eligible for execution; every other tool in the -/// registry is hidden from the model and rejected if the model -/// somehow emits a call for it. When `None`, no filtering is applied -/// and every tool in the combined registry is visible (the legacy -/// behaviour used by CLI/REPL and harness tests). -/// -/// * `extra_tools` — per-turn synthesised tools to splice alongside the -/// persistent `tools_registry`. The agent-dispatch path uses this to -/// surface delegation tools (`research`, `plan`, -/// `delegate_to_integrations_agent`, …) that are synthesised fresh -/// per turn from the active agent's `subagents` field and the -/// current Composio integration list, and therefore are not -/// registered in the global startup-time registry. -/// -/// The combined tool list seen by the LLM this turn is -/// `tools_registry.iter().chain(extra_tools.iter())`, further narrowed -/// by `visible_tool_names` when supplied. -#[allow(clippy::too_many_arguments)] -pub(crate) async fn run_tool_call_loop( - provider: &dyn Provider, - history: &mut Vec, - tools_registry: &[Box], - provider_name: &str, - model: &str, - temperature: f64, - silent: bool, - // Retained in the harness signature (callers pass their channel) but no - // longer consumed here since the legacy CLI approval prompt was removed — - // approval now flows through the process-global `ApprovalGate`. - _channel_name: &str, - multimodal_config: &crate::openhuman::config::MultimodalConfig, - multimodal_file_config: &crate::openhuman::config::MultimodalFileConfig, - max_tool_iterations: usize, - on_delta: Option>, - visible_tool_names: Option<&HashSet>, - extra_tools: &[Box], - on_progress: Option>, - payload_summarizer: Option<&dyn PayloadSummarizer>, - tool_policy: &dyn ToolPolicy, -) -> Result { - let max_iterations = if max_tool_iterations == 0 { - DEFAULT_MAX_TOOL_ITERATIONS - } else { - max_tool_iterations - }; - - // The agentic loop itself now lives in the shared turn engine; this - // function is a thin adapter that builds the channel/CLI tool source - // (registry + per-turn extras, visibility whitelist, pluggable policy) - // and hands off. The signature is retained verbatim so existing callers - // (the `agent.run_turn` bus handler, triage, the payload summarizer, and - // the harness test suite) are unaffected. - log::debug!( - "[tool-loop] Registry has {} tool(s), extra {} tool(s), filter={}", - tools_registry.len(), - extra_tools.len(), - visible_tool_names - .map(|s| format!("whitelist({})", s.len())) - .unwrap_or_else(|| "none".to_string()), - ); - let mut tool_source = super::engine::RegistryToolSource::new( - tools_registry, - extra_tools, - visible_tool_names, - tool_policy, - payload_summarizer, - ); - let progress = super::engine::TurnProgress::new(on_progress); - let mut observer = super::engine::NullObserver; - let checkpoint = super::engine::ErrorCheckpoint; - let parser = super::engine::DefaultParser; - super::engine::run_turn_engine( - provider, - history, - &mut tool_source, - &progress, - &mut observer, - &checkpoint, - &parser, - provider_name, - model, - temperature, - silent, - multimodal_config, - multimodal_file_config, - max_iterations, - AGENT_TURN_MAX_OUTPUT_TOKENS, - on_delta, - &[], - None, - None, // channel/CLI/triage loop: context guard + token-budget trim only - ) - .await - .map(|outcome| outcome.text) -} - -#[cfg(test)] -#[path = "tool_loop_tests.rs"] -mod tests; diff --git a/src/openhuman/agent/harness/tool_loop_tests.rs b/src/openhuman/agent/harness/tool_loop_tests.rs deleted file mode 100644 index de8f5f4d9..000000000 --- a/src/openhuman/agent/harness/tool_loop_tests.rs +++ /dev/null @@ -1,2190 +0,0 @@ -use super::*; -use crate::openhuman::inference::provider::traits::ProviderCapabilities; -use crate::openhuman::inference::provider::{ChatRequest, ChatResponse}; -use crate::openhuman::tools::{ToolResult, ToolScope}; -use async_trait::async_trait; -use parking_lot::Mutex; - -struct ScriptedProvider { - responses: Mutex>>, - native_tools: bool, - vision: bool, -} - -#[async_trait] -impl Provider for ScriptedProvider { - async fn chat_with_system( - &self, - _system_prompt: Option<&str>, - _message: &str, - _model: &str, - _temperature: f64, - ) -> Result { - Ok("fallback".into()) - } - - async fn chat( - &self, - _request: ChatRequest<'_>, - _model: &str, - _temperature: f64, - ) -> Result { - let mut guard = self.responses.lock(); - guard.remove(0) - } - - fn capabilities(&self) -> ProviderCapabilities { - ProviderCapabilities { - native_tool_calling: self.native_tools, - vision: self.vision, - ..ProviderCapabilities::default() - } - } -} - -struct EchoTool; - -#[async_trait] -impl Tool for EchoTool { - fn name(&self) -> &str { - "echo" - } - - fn description(&self) -> &str { - "echo" - } - - fn parameters_schema(&self) -> serde_json::Value { - serde_json::json!({"type":"object"}) - } - - async fn execute(&self, _args: serde_json::Value) -> Result { - Ok(ToolResult::success("echo-out")) - } -} - -struct CliOnlyTool; - -#[async_trait] -impl Tool for CliOnlyTool { - fn name(&self) -> &str { - "cli_only" - } - - fn description(&self) -> &str { - "cli only" - } - - fn parameters_schema(&self) -> serde_json::Value { - serde_json::json!({"type":"object"}) - } - - async fn execute(&self, _args: serde_json::Value) -> Result { - Ok(ToolResult::success("should-not-run")) - } - - fn scope(&self) -> ToolScope { - ToolScope::CliRpcOnly - } -} - -struct ErrorResultTool; - -#[async_trait] -impl Tool for ErrorResultTool { - fn name(&self) -> &str { - "error_result" - } - - fn description(&self) -> &str { - "error result" - } - - fn parameters_schema(&self) -> serde_json::Value { - serde_json::json!({"type":"object"}) - } - - async fn execute(&self, _args: serde_json::Value) -> Result { - Ok(ToolResult::error("explicit failure")) - } -} - -/// Simulates a delegated sub-agent (`run_code` / `tools_agent`) whose provider -/// call hit a permanent, non-retryable wall: `dispatch_subagent` converts that -/// sub-agent `Err` into a `ToolResult::error` carrying the budget-exhaustion -/// body. Used to prove the #3104 cascade halts on the FIRST such failure. -struct BudgetExhaustedDelegationTool; - -#[async_trait] -impl Tool for BudgetExhaustedDelegationTool { - fn name(&self) -> &str { - "run_code" - } - - fn description(&self) -> &str { - "delegate to the code executor (always 400s on budget here)" - } - - fn parameters_schema(&self) -> serde_json::Value { - serde_json::json!({"type":"object"}) - } - - async fn execute(&self, _args: serde_json::Value) -> Result { - // Mirrors dispatch.rs::format_subagent_failure wrapping the upstream body. - Ok(ToolResult::error( - "run_code failed and did not complete — no work was performed. \ - Error: OpenHuman API error (400): {\"error\":\"Insufficient budget\"}", - )) - } -} - -/// Records whether it ever executed. Used to prove that a tool placed AFTER a -/// terminal-inference failure in the SAME assistant-message batch never runs -/// (#3104 — Codex review #3779, batched tool calls). -struct RanTrackerTool { - ran: std::sync::Arc, -} - -#[async_trait] -impl Tool for RanTrackerTool { - fn name(&self) -> &str { - "ran_tracker" - } - - fn description(&self) -> &str { - "records that it executed" - } - - fn parameters_schema(&self) -> serde_json::Value { - serde_json::json!({"type":"object"}) - } - - async fn execute(&self, _args: serde_json::Value) -> Result { - self.ran.store(true, std::sync::atomic::Ordering::SeqCst); - Ok(ToolResult::success("ran-tracker-out")) - } -} - -struct FailingTool; - -#[async_trait] -impl Tool for FailingTool { - fn name(&self) -> &str { - "failing" - } - - fn description(&self) -> &str { - "failing" - } - - fn parameters_schema(&self) -> serde_json::Value { - serde_json::json!({"type":"object"}) - } - - async fn execute(&self, _args: serde_json::Value) -> Result { - anyhow::bail!("boom") - } -} - -/// Tool that emits a large payload (~150 KB), used to exercise the -/// payload-summarizer interception path in the integration test -/// below. -struct BigPayloadTool; - -#[async_trait] -impl Tool for BigPayloadTool { - fn name(&self) -> &str { - "big_payload" - } - - fn description(&self) -> &str { - "emits a 150 KB payload to trigger summarization" - } - - fn parameters_schema(&self) -> serde_json::Value { - serde_json::json!({"type":"object"}) - } - - async fn execute(&self, _args: serde_json::Value) -> Result { - // 150 KB of payload — well above the 100 KB default threshold. - Ok(ToolResult::success("X".repeat(150_000))) - } -} - -/// Mock summarizer that always returns a fixed compressed string, -/// used to verify that [`run_tool_call_loop`] swaps the raw tool -/// output for the summary before pushing it into history. -struct MockSummarizer { - summary: String, -} - -#[async_trait] -impl super::super::payload_summarizer::PayloadSummarizer for MockSummarizer { - async fn maybe_summarize( - &self, - _tool_name: &str, - _parent_task_hint: Option<&str>, - raw: &str, - ) -> Result> { - Ok(Some(super::super::payload_summarizer::SummarizedPayload { - summary: self.summary.clone(), - original_bytes: raw.len(), - summary_bytes: self.summary.len(), - })) - } -} - -#[tokio::test] -async fn run_tool_call_loop_intercepts_oversized_tool_results_via_summarizer() { - // Provider scripts a single tool call to `big_payload`, then a - // final "done" message after the tool result lands in history. - let provider = ScriptedProvider { - responses: Mutex::new(vec![ - Ok(ChatResponse { - text: Some( - "{\"name\":\"big_payload\",\"arguments\":{}}".into(), - ), - tool_calls: vec![], - usage: None, - reasoning_content: None, - }), - Ok(ChatResponse { - text: Some("done".into()), - tool_calls: vec![], - usage: None, - reasoning_content: None, - }), - ]), - native_tools: false, - vision: false, - }; - let mut history = vec![ChatMessage::user("dump the data")]; - let tools: Vec> = vec![Box::new(BigPayloadTool)]; - let summarizer = MockSummarizer { - summary: "compressed-summary-marker".to_string(), - }; - - let result = run_tool_call_loop( - &provider, - &mut history, - &tools, - "test-provider", - "model", - 0.0, - true, - "channel", - &crate::openhuman::config::MultimodalConfig::default(), - &crate::openhuman::config::MultimodalFileConfig::default(), - 2, - None, - None, - &[], - None, - Some(&summarizer), - &crate::openhuman::tools::policy::DefaultToolPolicy, - ) - .await - .expect("loop with summarizer should succeed"); - - assert_eq!(result, "done"); - - // The summarized marker should be present in the appended - // tool-results message; the raw 150 KB blob of 'X' should NOT. - let tool_results = history - .iter() - .find(|msg| msg.role == "user" && msg.content.contains("[Tool results]")) - .expect("tool results should be appended"); - assert!( - tool_results.content.contains("compressed-summary-marker"), - "summarizer output should replace the raw payload in history" - ); - // 150 KB of "X" is much larger than the summary; if it slipped - // through, the message body would be enormous. - assert!( - tool_results.content.len() < 10_000, - "raw 150 KB payload must not appear in history (got {} bytes)", - tool_results.content.len() - ); -} - -#[tokio::test] -async fn run_tool_call_loop_rejects_vision_markers_for_non_vision_provider() { - let provider = ScriptedProvider { - responses: Mutex::new(vec![]), - native_tools: false, - vision: false, - }; - let mut history = vec![ChatMessage::user("look [IMAGE:/tmp/x.png]")]; - - let err = run_tool_call_loop( - &provider, - &mut history, - &[], - "test-provider", - "model", - 0.0, - true, - "channel", - &crate::openhuman::config::MultimodalConfig::default(), - &crate::openhuman::config::MultimodalFileConfig::default(), - 1, - None, - None, - &[], - None, - None, - &crate::openhuman::tools::policy::DefaultToolPolicy, - ) - .await - .expect_err("vision markers should be rejected"); - - assert!(err.to_string().contains("does not support vision input")); -} - -#[tokio::test] -async fn run_tool_call_loop_streams_final_text_chunks() { - let provider = ScriptedProvider { - responses: Mutex::new(vec![Ok(ChatResponse { - text: Some("word ".repeat(30)), - tool_calls: vec![], - usage: None, - reasoning_content: None, - })]), - native_tools: false, - vision: false, - }; - let mut history = vec![ChatMessage::user("hello")]; - let (tx, mut rx) = tokio::sync::mpsc::channel(8); - - let result = run_tool_call_loop( - &provider, - &mut history, - &[], - "test-provider", - "model", - 0.0, - true, - "channel", - &crate::openhuman::config::MultimodalConfig::default(), - &crate::openhuman::config::MultimodalFileConfig::default(), - 1, - Some(tx), - None, - &[], - None, - None, - &crate::openhuman::tools::policy::DefaultToolPolicy, - ) - .await - .expect("final text should succeed"); - - let mut streamed = String::new(); - while let Some(chunk) = rx.recv().await { - streamed.push_str(&chunk); - } - - assert_eq!(result, streamed); - assert!(history.iter().any(|msg| msg.role == "assistant")); -} - -#[tokio::test] -async fn run_tool_call_loop_blocks_cli_rpc_only_tools_in_prompt_mode() { - let provider = ScriptedProvider { - responses: Mutex::new(vec![ - Ok(ChatResponse { - text: Some( - "{\"name\":\"cli_only\",\"arguments\":{}}".into(), - ), - tool_calls: vec![], - usage: None, - reasoning_content: None, - }), - Ok(ChatResponse { - text: Some("done".into()), - tool_calls: vec![], - usage: None, - reasoning_content: None, - }), - ]), - native_tools: false, - vision: false, - }; - let mut history = vec![ChatMessage::user("hello")]; - let tools: Vec> = vec![Box::new(CliOnlyTool)]; - - let result = run_tool_call_loop( - &provider, - &mut history, - &tools, - "test-provider", - "model", - 0.0, - true, - "channel", - &crate::openhuman::config::MultimodalConfig::default(), - &crate::openhuman::config::MultimodalFileConfig::default(), - 2, - None, - None, - &[], - None, - None, - &crate::openhuman::tools::policy::DefaultToolPolicy, - ) - .await - .expect("loop should recover after denial"); - - assert_eq!(result, "done"); - let tool_results = history - .iter() - .find(|msg| msg.role == "user" && msg.content.contains("[Tool results]")) - .expect("tool results should be appended"); - assert!(tool_results - .content - .contains("only available via explicit CLI/RPC invocation")); -} - -#[tokio::test] -async fn run_tool_call_loop_persists_native_tool_results_as_tool_messages() { - let provider = ScriptedProvider { - responses: Mutex::new(vec![ - Ok(ChatResponse { - text: Some(String::new()), - tool_calls: vec![crate::openhuman::inference::provider::ToolCall { - id: "call-1".into(), - name: "echo".into(), - arguments: "{}".into(), - extra_content: None, - }], - usage: None, - reasoning_content: None, - }), - Ok(ChatResponse { - text: Some("done".into()), - tool_calls: vec![], - usage: None, - reasoning_content: None, - }), - ]), - native_tools: true, - vision: false, - }; - let mut history = vec![ChatMessage::user("hello")]; - let tools: Vec> = vec![Box::new(EchoTool)]; - - let result = run_tool_call_loop( - &provider, - &mut history, - &tools, - "test-provider", - "model", - 0.0, - true, - "channel", - &crate::openhuman::config::MultimodalConfig::default(), - &crate::openhuman::config::MultimodalFileConfig::default(), - 2, - None, - None, - &[], - None, - None, - &crate::openhuman::tools::policy::DefaultToolPolicy, - ) - .await - .expect("native tool flow should succeed"); - - assert_eq!(result, "done"); - let tool_msg = history - .iter() - .find(|msg| msg.role == "tool") - .expect("native tool result should be persisted"); - assert!(tool_msg.content.contains("\"tool_call_id\":\"call-1\"")); - assert!(tool_msg.content.contains("echo-out")); -} - -/// Behavioral end-to-end test of the `resolve_time` fix through the *real* -/// agent tool loop. The model is scripted only to *decide* to call -/// `resolve_time` (the part a live LLM does); the loop then dispatches to the -/// real registered tool, executes it, and threads the result back into the -/// conversation — exactly the path that was broken when the integrations -/// agent hand-computed "24h ago" as a ~10-month-wrong epoch. We assert the -/// resolved value the next turn would consume is the *correct* timestamp. -#[tokio::test] -async fn run_tool_call_loop_executes_real_resolve_time_and_threads_back_correct_epoch() { - let provider = ScriptedProvider { - responses: Mutex::new(vec![ - // Turn 1: the model asks to resolve the caller's window. This is - // the only thing we script — the value comes from the real tool. - Ok(ChatResponse { - text: Some(String::new()), - tool_calls: vec![crate::openhuman::inference::provider::ToolCall { - id: "call-rt".into(), - name: "resolve_time".into(), - arguments: "{\"expr\":\"2026-06-09T19:12:00Z\"}".into(), - extra_content: None, - }], - usage: None, - reasoning_content: None, - }), - // Turn 2: model wraps up once it has the resolved value. - Ok(ChatResponse { - text: Some("done".into()), - tool_calls: vec![], - usage: None, - reasoning_content: None, - }), - ]), - native_tools: true, - vision: false, - }; - let mut history = vec![ChatMessage::user( - "messages since 2026-06-09T19:12:00Z please", - )]; - // The REAL tool, resolved + executed by the loop (not a stub). - let tools: Vec> = vec![Box::new(crate::openhuman::tools::ResolveTimeTool::new())]; - - let result = run_tool_call_loop( - &provider, - &mut history, - &tools, - "test-provider", - "model", - 0.0, - true, - "channel", - &crate::openhuman::config::MultimodalConfig::default(), - &crate::openhuman::config::MultimodalFileConfig::default(), - 2, - None, - None, - &[], - None, - None, - &crate::openhuman::tools::policy::DefaultToolPolicy, - ) - .await - .expect("resolve_time tool flow should succeed"); - - assert_eq!(result, "done"); - let tool_msg = history - .iter() - .find(|msg| msg.role == "tool") - .expect("resolve_time result should be persisted as a tool message"); - // The correct epoch for 2026-06-09T19:12:00Z. The real incident's agent - // hand-computed this as 1752189120 (2025-07-10) — ~10 months wrong — and - // fetched the wrong Slack window. The tool returns the right value, ready - // for the next turn to pass as `oldest`. - assert!( - tool_msg.content.contains("1781032320"), - "expected the correctly resolved unix_s in the tool result; got: {}", - tool_msg.content - ); - assert!( - tool_msg.content.contains("1781032320.000000"), - "expected the slack_ts representation in the tool result; got: {}", - tool_msg.content - ); - // The ~10-month-wrong value the unfixed path produced must never appear. - assert!( - !tool_msg.content.contains("1752189120"), - "tool result must not contain the miscomputed epoch" - ); -} - -#[tokio::test] -async fn run_tool_call_loop_reports_unknown_tool_and_uses_default_max_iterations() { - let provider = ScriptedProvider { - responses: Mutex::new(vec![ - Ok(ChatResponse { - text: Some("{\"name\":\"missing\",\"arguments\":{}}".into()), - tool_calls: vec![], - usage: None, - reasoning_content: None, - }), - Ok(ChatResponse { - text: Some("done".into()), - tool_calls: vec![], - usage: None, - reasoning_content: None, - }), - ]), - native_tools: false, - vision: false, - }; - let mut history = vec![ChatMessage::user("hello")]; - - let result = run_tool_call_loop( - &provider, - &mut history, - &[], - "test-provider", - "model", - 0.0, - true, - "channel", - &crate::openhuman::config::MultimodalConfig::default(), - &crate::openhuman::config::MultimodalFileConfig::default(), - 0, - None, - None, - &[], - None, - None, - &crate::openhuman::tools::policy::DefaultToolPolicy, - ) - .await - .expect("default iteration fallback should still succeed"); - - assert_eq!(result, "done"); - let tool_results = history - .iter() - .find(|msg| msg.role == "user" && msg.content.contains("[Tool results]")) - .expect("tool results should be appended"); - assert!(tool_results.content.contains("Unknown tool: missing")); -} - -#[tokio::test] -async fn run_tool_call_loop_formats_tool_error_paths() { - let provider = ScriptedProvider { - responses: Mutex::new(vec![ - Ok(ChatResponse { - text: Some( - concat!( - "{\"name\":\"error_result\",\"arguments\":{}}", - "{\"name\":\"failing\",\"arguments\":{}}" - ) - .into(), - ), - tool_calls: vec![], - usage: None, - reasoning_content: None, - }), - Ok(ChatResponse { - text: Some("done".into()), - tool_calls: vec![], - usage: None, - reasoning_content: None, - }), - ]), - native_tools: false, - vision: false, - }; - let mut history = vec![ChatMessage::user("hello")]; - let tools: Vec> = vec![Box::new(ErrorResultTool), Box::new(FailingTool)]; - - let result = run_tool_call_loop( - &provider, - &mut history, - &tools, - "test-provider", - "model", - 0.0, - true, - "channel", - &crate::openhuman::config::MultimodalConfig::default(), - &crate::openhuman::config::MultimodalFileConfig::default(), - 2, - None, - None, - &[], - None, - None, - &crate::openhuman::tools::policy::DefaultToolPolicy, - ) - .await - .expect("loop should recover after tool errors"); - - assert_eq!(result, "done"); - let tool_results = history - .iter() - .find(|msg| msg.role == "user" && msg.content.contains("[Tool results]")) - .expect("tool results should be appended"); - assert!(tool_results.content.contains("Error: explicit failure")); - assert!(tool_results - .content - .contains("Error executing failing: boom")); -} - -#[tokio::test] -async fn run_tool_call_loop_propagates_provider_errors_and_max_iteration_failures() { - let failing_provider = ScriptedProvider { - responses: Mutex::new(vec![Err(anyhow::anyhow!("provider failed"))]), - native_tools: false, - vision: false, - }; - let mut history = vec![ChatMessage::user("hello")]; - let err = run_tool_call_loop( - &failing_provider, - &mut history, - &[], - "test-provider", - "model", - 0.0, - true, - "channel", - &crate::openhuman::config::MultimodalConfig::default(), - &crate::openhuman::config::MultimodalFileConfig::default(), - 1, - None, - None, - &[], - None, - None, - &crate::openhuman::tools::policy::DefaultToolPolicy, - ) - .await - .expect_err("provider error path should fail"); - assert!(err.to_string().contains("provider failed")); - - let looping_provider = ScriptedProvider { - responses: Mutex::new(vec![Ok(ChatResponse { - text: Some("{\"name\":\"echo\",\"arguments\":{}}".into()), - tool_calls: vec![], - usage: None, - reasoning_content: None, - })]), - native_tools: false, - vision: false, - }; - let mut looping_history = vec![ChatMessage::user("hello")]; - let tools: Vec> = vec![Box::new(EchoTool)]; - let err = run_tool_call_loop( - &looping_provider, - &mut looping_history, - &tools, - "test-provider", - "model", - 0.0, - true, - "channel", - &crate::openhuman::config::MultimodalConfig::default(), - &crate::openhuman::config::MultimodalFileConfig::default(), - 1, - None, - None, - &[], - None, - None, - &crate::openhuman::tools::policy::DefaultToolPolicy, - ) - .await - .expect_err("loop should stop after configured iterations"); - assert!(err - .to_string() - .contains("Agent exceeded maximum tool iterations (1)")); -} - -#[tokio::test] -async fn run_tool_call_loop_aborts_when_stop_hook_returns_stop() { - use crate::openhuman::agent::stop_hooks::{with_stop_hooks, StopDecision, StopHook, TurnState}; - use std::sync::atomic::{AtomicU32, Ordering}; - use std::sync::Arc; - - /// Stops the loop on the second iteration (1-based). - struct StopOnIteration(Arc); - - #[async_trait] - impl StopHook for StopOnIteration { - fn name(&self) -> &str { - "test-iter-cap" - } - async fn check(&self, ctx: &TurnState<'_>) -> StopDecision { - self.0.store(ctx.iteration, Ordering::Relaxed); - if ctx.iteration >= 2 { - StopDecision::Stop { - reason: "tripped on iter 2".into(), - } - } else { - StopDecision::Continue - } - } - } - - // Provider would happily loop forever — first response asks for a - // tool, second response would too (we never reach it because the - // stop hook fires at the top of iteration 2). - let provider = ScriptedProvider { - responses: Mutex::new(vec![ - Ok(ChatResponse { - text: Some("{\"name\":\"echo\",\"arguments\":{}}".into()), - tool_calls: vec![], - usage: None, - reasoning_content: None, - }), - Ok(ChatResponse { - text: Some("{\"name\":\"echo\",\"arguments\":{}}".into()), - tool_calls: vec![], - usage: None, - reasoning_content: None, - }), - ]), - native_tools: false, - vision: false, - }; - let mut history = vec![ChatMessage::user("loop me")]; - let tools: Vec> = vec![Box::new(EchoTool)]; - let last_seen = Arc::new(AtomicU32::new(0)); - let hook: Arc = Arc::new(StopOnIteration(last_seen.clone())); - - let err = with_stop_hooks(vec![hook], async { - run_tool_call_loop( - &provider, - &mut history, - &tools, - "test-provider", - "model", - 0.0, - true, - "channel", - &crate::openhuman::config::MultimodalConfig::default(), - &crate::openhuman::config::MultimodalFileConfig::default(), - 10, - None, - None, - &[], - None, - None, - &crate::openhuman::tools::policy::DefaultToolPolicy, - ) - .await - }) - .await - .expect_err("stop hook should abort the loop"); - - assert!( - err.to_string().contains("stopped by hook 'test-iter-cap'"), - "got: {err}" - ); - assert!( - err.to_string().contains("tripped on iter 2"), - "stop reason should be propagated, got: {err}" - ); - assert_eq!( - last_seen.load(Ordering::Relaxed), - 2, - "hook should have observed iteration 2" - ); -} - -#[tokio::test] -async fn run_tool_call_loop_runs_unchanged_when_no_stop_hooks_installed() { - // Sanity: with no `with_stop_hooks` scope, the loop behaves - // identically to before this feature landed. - let provider = ScriptedProvider { - responses: Mutex::new(vec![Ok(ChatResponse { - text: Some("done".into()), - tool_calls: vec![], - usage: None, - reasoning_content: None, - })]), - native_tools: false, - vision: false, - }; - let mut history = vec![ChatMessage::user("hi")]; - let result = run_tool_call_loop( - &provider, - &mut history, - &[], - "test-provider", - "model", - 0.0, - true, - "channel", - &crate::openhuman::config::MultimodalConfig::default(), - &crate::openhuman::config::MultimodalFileConfig::default(), - 1, - None, - None, - &[], - None, - None, - &crate::openhuman::tools::policy::DefaultToolPolicy, - ) - .await - .expect("loop should succeed without stop hooks"); - assert_eq!(result, "done"); -} - -#[tokio::test] -async fn run_tool_call_loop_applies_per_tool_max_result_size_cap() { - /// Tool that emits a 200k-char body and declares a 100-char cap - /// via `max_result_size_chars`. The loop should truncate before - /// threading the body into history. - struct CappedHugeTool; - - #[async_trait] - impl Tool for CappedHugeTool { - fn name(&self) -> &str { - "capped_huge" - } - fn description(&self) -> &str { - "emits a giant body but caps itself" - } - fn parameters_schema(&self) -> serde_json::Value { - serde_json::json!({"type": "object"}) - } - async fn execute(&self, _args: serde_json::Value) -> Result { - Ok(ToolResult::success("Z".repeat(200_000))) - } - fn permission_level(&self) -> crate::openhuman::tools::PermissionLevel { - crate::openhuman::tools::PermissionLevel::ReadOnly - } - fn max_result_size_chars(&self) -> Option { - Some(100) - } - } - - let provider = ScriptedProvider { - responses: Mutex::new(vec![ - // Round 1: ask for the tool. - Ok(ChatResponse { - text: Some( - "{\"name\":\"capped_huge\",\"arguments\":{}}".into(), - ), - tool_calls: vec![], - usage: None, - reasoning_content: None, - }), - // Round 2: stop. - Ok(ChatResponse { - text: Some("done".into()), - tool_calls: vec![], - usage: None, - reasoning_content: None, - }), - ]), - native_tools: false, - vision: false, - }; - let mut history = vec![ChatMessage::user("call the tool")]; - let tools: Vec> = vec![Box::new(CappedHugeTool)]; - - let result = run_tool_call_loop( - &provider, - &mut history, - &tools, - "test-provider", - "model", - 0.0, - true, - "channel", - &crate::openhuman::config::MultimodalConfig::default(), - &crate::openhuman::config::MultimodalFileConfig::default(), - 2, - None, - None, - &[], - None, - None, - &crate::openhuman::tools::policy::DefaultToolPolicy, - ) - .await - .expect("loop with capped tool should succeed"); - assert_eq!(result, "done"); - - // Tool-results message should contain the truncation marker and - // be far smaller than the 200k raw body (the 100-char cap plus a - // small marker, well under 1k bytes total for this one call). - let tool_results = history - .iter() - .find(|msg| msg.role == "user" && msg.content.contains("[Tool results]")) - .expect("tool results should be appended to history"); - assert!( - tool_results.content.contains("[truncated by tool cap:"), - "expected truncation marker, got body: {}", - crate::openhuman::util::utf8_safe_prefix_at_byte_boundary(&tool_results.content, 200) - ); - assert!( - tool_results.content.len() < 1_000, - "raw 200k payload should not appear in history (got {} bytes)", - tool_results.content.len() - ); -} - -/// Repeated-failure circuit breaker: when the model re-issues the IDENTICAL -/// failing call, the loop must halt early with a root-cause summary instead of -/// grinding to `max_iterations` and returning `MaxIterationsExceeded`. -#[tokio::test] -async fn run_tool_call_loop_halts_on_repeated_identical_failure() { - // Script the same `error_result` call (identical args) far more times than - // the REPEAT_FAILURE_THRESHOLD (3); the loop should stop after the 3rd. - let mut responses: Vec> = Vec::new(); - for _ in 0..10 { - responses.push(Ok(ChatResponse { - text: Some( - "{\"name\":\"error_result\",\"arguments\":{}}".into(), - ), - tool_calls: vec![], - usage: None, - reasoning_content: None, - })); - } - let provider = ScriptedProvider { - responses: Mutex::new(responses), - native_tools: false, - vision: false, - }; - let mut history = vec![ChatMessage::user("install the thing")]; - let tools: Vec> = vec![Box::new(ErrorResultTool)]; - - let result = run_tool_call_loop( - &provider, - &mut history, - &tools, - "test-provider", - "model", - 0.0, - true, - "channel", - &crate::openhuman::config::MultimodalConfig::default(), - &crate::openhuman::config::MultimodalFileConfig::default(), - 10, // max_iterations — must NOT be reached; breaker fires at 3 - None, - None, - &[], - None, - None, - &crate::openhuman::tools::policy::DefaultToolPolicy, - ) - .await - .expect("repeated-failure halt returns Ok with a root-cause summary, not an error"); - - assert!( - result.contains("Stopping") && result.contains("retried 3 times"), - "expected an early repeated-failure halt summary, got: {result}" - ); - assert!( - result.contains("explicit failure"), - "halt summary should embed the underlying error, got: {result}" - ); - // Breaker fired at the 3rd identical failure → only 3 of the 10 scripted - // responses consumed (7 remain). Proves it did NOT grind to max_iterations. - assert_eq!( - provider.responses.lock().len(), - 7, - "loop should consume exactly 3 LLM turns before halting" - ); -} - -/// No-progress circuit breaker: even with VARIED arguments (so no single -/// signature repeats), a run of back-to-back failures with zero success halts -/// once it hits NO_PROGRESS_FAILURE_THRESHOLD (6). -#[tokio::test] -async fn run_tool_call_loop_halts_when_no_progress() { - let mut responses = Vec::new(); - for i in 0..10 { - // Distinct args each turn → per-signature count stays at 1, so only the - // consecutive-failure guard can trip. - responses.push(Ok(ChatResponse { - text: Some(format!( - "{{\"name\":\"error_result\",\"arguments\":{{\"i\":{i}}}}}" - )), - tool_calls: vec![], - usage: None, - reasoning_content: None, - })); - } - let provider = ScriptedProvider { - responses: Mutex::new(responses), - native_tools: false, - vision: false, - }; - let mut history = vec![ChatMessage::user("keep trying")]; - let tools: Vec> = vec![Box::new(ErrorResultTool)]; - - let result = run_tool_call_loop( - &provider, - &mut history, - &tools, - "test-provider", - "model", - 0.0, - true, - "channel", - &crate::openhuman::config::MultimodalConfig::default(), - &crate::openhuman::config::MultimodalFileConfig::default(), - 20, - None, - None, - &[], - None, - None, - &crate::openhuman::tools::policy::DefaultToolPolicy, - ) - .await - .expect("no-progress halt returns Ok with a summary"); - - assert!( - result.contains("Stopping") && result.contains("in a row failed"), - "expected a no-progress halt summary, got: {result}" - ); - // Fires at the 6th consecutive failure → 6 of 10 responses consumed. - assert_eq!( - provider.responses.lock().len(), - 4, - "loop should consume exactly 6 LLM turns before halting on no-progress" - ); -} - -/// #3104 end-to-end repro through the real engine: a delegation that fails with -/// a PERMANENT inference condition (out of budget) must halt the orchestrator on -/// the FIRST failure with an actionable root cause — NOT grind to the -/// 6-consecutive no-progress backstop (the Plan → Run Code ×6 → Tools Agent ×2 -/// cascade the user saw). Before the fix this loop consumed 6 LLM turns and -/// ended in a generic message; after it, exactly 1. -#[tokio::test] -async fn run_tool_call_loop_halts_on_first_budget_exhausted_delegation() { - let mut responses = Vec::new(); - for i in 0..10 { - // Varied args so the per-signature repeat guard can NEVER trip — only the - // terminal-inference check (or, pre-fix, the 6-consecutive backstop) can. - responses.push(Ok(ChatResponse { - text: Some(format!( - "{{\"name\":\"run_code\",\"arguments\":{{\"step\":{i}}}}}" - )), - tool_calls: vec![], - usage: None, - reasoning_content: None, - })); - } - let provider = ScriptedProvider { - responses: Mutex::new(responses), - native_tools: false, - vision: false, - }; - let mut history = vec![ChatMessage::user("build me a dashboard")]; - let tools: Vec> = vec![Box::new(BudgetExhaustedDelegationTool)]; - - let result = run_tool_call_loop( - &provider, - &mut history, - &tools, - "test-provider", - "model", - 0.0, - true, - "channel", - &crate::openhuman::config::MultimodalConfig::default(), - &crate::openhuman::config::MultimodalFileConfig::default(), - 10, // max_iterations — must NOT be reached; terminal halt fires at 1 - None, - None, - &[], - None, - None, - &crate::openhuman::tools::policy::DefaultToolPolicy, - ) - .await - .expect("budget-exhausted halt returns Ok with an actionable summary"); - - assert!( - result.contains("Stopping") && result.contains("out of inference budget"), - "expected an actionable budget halt, got: {result}" - ); - // The decisive assertion: only the FIRST scripted turn was consumed (9 of 10 - // remain). Proves the cascade is killed on the first permanent failure - // instead of burning 6 doomed, paid delegations. - assert_eq!( - provider.responses.lock().len(), - 9, - "loop must halt after exactly 1 turn on a permanent inference failure" - ); -} - -/// #3104 batched-tool-call guarantee (Codex review #3779): when a single -/// assistant message emits MULTIPLE tool calls and the FIRST records a terminal -/// inference failure (out of budget / provider-config), the loop must STOP -/// executing the rest of the batch — so a second delegated call in the same -/// message can never launch a paid sub-agent after the first proved the wall is -/// unrecoverable. Pre-fix the loop set `halt_reason` but drained the batch first. -#[tokio::test] -async fn run_tool_call_loop_stops_batch_after_first_terminal_failure() { - use std::sync::atomic::{AtomicBool, Ordering}; - use std::sync::Arc; - - // One assistant message with TWO tool calls: the budget-exhausted delegation - // first, a tracker second. Native mode emits both as `tool_calls` in a single - // ChatResponse so they share one batch iteration. - let provider = ScriptedProvider { - responses: Mutex::new(vec![Ok(ChatResponse { - text: Some(String::new()), - tool_calls: vec![ - crate::openhuman::inference::provider::ToolCall { - id: "call-budget".into(), - name: "run_code".into(), - arguments: "{}".into(), - extra_content: None, - }, - crate::openhuman::inference::provider::ToolCall { - id: "call-tracker".into(), - name: "ran_tracker".into(), - arguments: "{}".into(), - extra_content: None, - }, - ], - usage: None, - reasoning_content: None, - })]), - native_tools: true, - vision: false, - }; - let mut history = vec![ChatMessage::user("build me a dashboard")]; - let ran = Arc::new(AtomicBool::new(false)); - let tools: Vec> = vec![ - Box::new(BudgetExhaustedDelegationTool), - Box::new(RanTrackerTool { ran: ran.clone() }), - ]; - - let result = run_tool_call_loop( - &provider, - &mut history, - &tools, - "test-provider", - "model", - 0.0, - true, - "channel", - &crate::openhuman::config::MultimodalConfig::default(), - &crate::openhuman::config::MultimodalFileConfig::default(), - 5, - None, - None, - &[], - None, - None, - &crate::openhuman::tools::policy::DefaultToolPolicy, - ) - .await - .expect("budget-exhausted halt returns Ok with an actionable summary"); - - assert!( - result.contains("Stopping") && result.contains("out of inference budget"), - "expected an actionable budget halt, got: {result}" - ); - // The decisive assertion: the SECOND call in the batch must NOT have run. - assert!( - !ran.load(Ordering::SeqCst), - "a tool placed after a terminal inference failure in the same batch must NOT execute" - ); -} - -// -- RepeatFailureGuard (shared by run_tool_call_loop + run_inner_loop) -------- - -#[test] -fn repeat_failure_guard_halts_on_3_identical() { - let mut g = RepeatFailureGuard::new(); - assert!(g - .record("shell", "pip install yfinance", false, "err") - .is_none()); - assert!(g - .record("shell", "pip install yfinance", false, "err") - .is_none()); - let halt = g.record( - "shell", - "pip install yfinance", - false, - "externally-managed-environment", - ); - assert!(halt.is_some(), "same call failing 3x must trip the breaker"); - assert!(halt.unwrap().contains("externally-managed-environment")); -} - -/// End-to-end intent for the code_executor manifestation (#4095): once the -/// shell-family tools surface the exit code (the `command_output` formatter), a -/// dependency wall (`exit code 127 — command not found …`) re-issued with -/// IDENTICAL args trips the shared breaker within `REPEAT_FAILURE_THRESHOLD` and -/// returns an actionable halt summary — so the turn ends with a surfaced result, -/// not a silent ~10× retry loop. The string here mirrors what -/// `system::command_output::render_command_failure(Some(127), …)` now produces. -#[test] -fn repeat_failure_guard_halts_on_surfaced_dependency_wall() { - let surfaced = "Error: Command failed (exit code 127 — command not found: a required \ - executable or dependency is missing or not on PATH. Install/declare it, \ - use an available alternative, or report the blocker — do NOT re-run the \ - same command)\n[stderr]\npytest: command not found"; - let mut g = RepeatFailureGuard::new(); - assert!(g.record("shell", "pytest -q", false, surfaced).is_none()); - assert!(g.record("shell", "pytest -q", false, surfaced).is_none()); - let halt = g.record("shell", "pytest -q", false, surfaced); - assert!( - halt.is_some(), - "an identical dependency-wall failure must trip the breaker within \ - REPEAT_FAILURE_THRESHOLD instead of looping" - ); - let summary = halt.unwrap(); - assert!( - summary.contains("127") || summary.contains("command not found"), - "halt summary should carry the surfaced root cause: {summary}" - ); -} - -#[test] -fn repeat_failure_guard_halts_on_6_consecutive_varied() { - let mut g = RepeatFailureGuard::new(); - // Distinct signatures → repeat guard never trips; only the consecutive run does. - for i in 0..5 { - assert!(g.record("shell", &format!("cmd{i}"), false, "e").is_none()); - } - assert!( - g.record("shell", "cmd5", false, "e").is_some(), - "6 consecutive failures must trip the no-progress guard" - ); -} - -#[test] -fn recoverable_failure_classifier_recognizes_timeouts_and_transients() { - for recoverable in [ - "Error: tool 'shell' timed out after 60 seconds", - "Command timed out after 60s and was killed", - "deadline exceeded while fetching", - "connection reset by peer", - "503 Service Unavailable", - "rate limit exceeded; retry after 1s", - ] { - assert!( - is_recoverable_tool_failure(recoverable), - "expected recoverable marker in {recoverable:?}" - ); - } - - for terminal in [ - "externally-managed-environment", - "No such file or directory", - "permission denied", - "syntax error near unexpected token", - ] { - assert!( - !is_recoverable_tool_failure(terminal), - "non-transient failures should keep the generic breaker path: {terminal:?}" - ); - } -} - -#[test] -fn recoverable_identical_failures_get_extended_headroom() { - let mut g = RepeatFailureGuard::new(); - let timeout = "Error: tool 'shell' timed out after 60 seconds"; - - for i in 0..(REPEAT_FAILURE_THRESHOLD + 2) { - assert!( - g.record("shell", "python solve.py", false, timeout) - .is_none(), - "recoverable identical timeout should not halt at generic failure count {}", - i + 1 - ); - } - - for i in (REPEAT_FAILURE_THRESHOLD + 2)..(RECOVERABLE_REPEAT_FAILURE_THRESHOLD - 1) { - assert!( - g.record("shell", "python solve.py", false, timeout) - .is_none(), - "recoverable identical timeout should keep headroom until count {}", - i + 1 - ); - } - - let halt = g.record("shell", "python solve.py", false, timeout); - let msg = halt.expect("identical recoverable failures still eventually trip"); - assert!( - msg.contains(&format!( - "retried {} times", - RECOVERABLE_REPEAT_FAILURE_THRESHOLD - )), - "got: {msg}" - ); - assert!( - msg.contains("extended transient-failure headroom"), - "recoverable halt should explain why it waited longer: {msg}" - ); -} - -#[test] -fn recoverable_varied_failures_do_not_trip_generic_no_progress() { - let mut g = RepeatFailureGuard::new(); - let timeout = "Error: tool 'shell' timed out after 60 seconds"; - - for i in 0..(NO_PROGRESS_FAILURE_THRESHOLD + 2) { - assert!( - g.record( - "shell", - &format!("python solve.py --attempt={i}"), - false, - timeout - ) - .is_none(), - "varied recoverable failures should not halt at generic no-progress count {}", - i + 1 - ); - } - - for i in (NO_PROGRESS_FAILURE_THRESHOLD + 2)..(RECOVERABLE_NO_PROGRESS_FAILURE_THRESHOLD - 1) { - assert!( - g.record( - "shell", - &format!("python solve.py --attempt={i}"), - false, - timeout - ) - .is_none(), - "varied recoverable failures should keep headroom until count {}", - i + 1 - ); - } - - let halt = g.record( - "shell", - &format!( - "python solve.py --attempt={}", - RECOVERABLE_NO_PROGRESS_FAILURE_THRESHOLD - 1 - ), - false, - timeout, - ); - let msg = halt.expect("recoverable no-progress failures remain bounded"); - assert!( - msg.contains(&format!( - "{} recoverable-looking tool failures", - RECOVERABLE_NO_PROGRESS_FAILURE_THRESHOLD - )), - "got: {msg}" - ); -} - -#[test] -fn repeat_failure_guard_success_resets_consecutive() { - let mut g = RepeatFailureGuard::new(); - for i in 0..5 { - g.record("shell", &format!("cmd{i}"), false, "e"); - } - assert!( - g.record("shell", "ok", true, "fine").is_none(), - "success returns None" - ); - // After a success the consecutive counter is back to 0, so one more failure - // is nowhere near the 6-in-a-row threshold. - assert!(g.record("shell", "cmd6", false, "e").is_none()); -} - -// -- Hard policy rejects (marker-driven, halt on first verbatim repeat) --------- - -#[test] -fn hard_reject_kind_detects_markers() { - use crate::openhuman::security::{POLICY_BLOCKED_MARKER, POLICY_DENIED_MARKER}; - // Marker survives the `Error: …` wrapping the tool/subagent layers add. - assert_eq!( - hard_reject_kind(&format!("Error: {POLICY_BLOCKED_MARKER} Path not allowed")), - Some(HardReject::Blocked) - ); - assert_eq!( - hard_reject_kind(&format!("{POLICY_DENIED_MARKER} User denied 'shell'.")), - Some(HardReject::Denied) - ); - assert_eq!(hard_reject_kind("Error: connection reset by peer"), None); -} - -#[test] -fn hard_reject_blocked_halts_on_first_repeat_not_third() { - use crate::openhuman::security::POLICY_BLOCKED_MARKER; - let mut g = RepeatFailureGuard::new(); - let blocked = - format!("Error: {POLICY_BLOCKED_MARKER} Path not allowed by security policy: /etc"); - // First occurrence is allowed through so the model can read the reason and pivot. - assert!( - g.record("file_read", "/etc/passwd", false, &blocked) - .is_none(), - "first hard reject should not halt — let the model change approach" - ); - // Second identical attempt = first verbatim repeat → halt (vs the generic 3). - let halt = g.record("file_read", "/etc/passwd", false, &blocked); - assert!( - halt.is_some(), - "an identical blocked call must halt on the 2nd attempt" - ); - let msg = halt.unwrap(); - assert!(msg.contains("blocked by the security policy"), "got: {msg}"); -} - -#[test] -fn hard_reject_denied_halts_on_first_repeat() { - use crate::openhuman::security::POLICY_DENIED_MARKER; - let mut g = RepeatFailureGuard::new(); - let denied = format!("Error: {POLICY_DENIED_MARKER} User denied 'shell' execution."); - assert!(g.record("shell", "rm -rf build", false, &denied).is_none()); - let halt = g.record("shell", "rm -rf build", false, &denied); - assert!( - halt.is_some(), - "re-issued denied call must halt on the 2nd attempt" - ); - assert!(halt.unwrap().contains("denied and re-issued")); -} - -#[test] -fn hard_reject_distinct_args_do_not_trip_repeat() { - use crate::openhuman::security::POLICY_BLOCKED_MARKER; - let mut g = RepeatFailureGuard::new(); - let mk = POLICY_BLOCKED_MARKER; - // Different forbidden paths each time: the per-signature repeat guard never - // trips (every signature is seen once); only the no-progress backstop can. - for i in 0..5 { - assert!(g - .record( - "file_read", - &format!("/etc/x{i}"), - false, - &format!("{mk} blocked") - ) - .is_none()); - } - assert!( - g.record("file_read", "/etc/x5", false, &format!("{mk} blocked")) - .is_some(), - "6 distinct hard rejects in a row should still trip the no-progress guard" - ); -} - -// -- Terminal inference failures (#3104: budget / provider-config cascade) ------- - -#[test] -fn terminal_inference_failure_kind_classifies_budget_and_config() { - // Budget wins precedence; provider-config covers the user-state model/key - // rejections; transient / 5xx / generic-4xx match NEITHER (so retryable - // failures keep their normal consecutive-failure grace). - assert_eq!( - terminal_inference_failure_kind( - "run_code failed and did not complete. Error: OpenHuman API error (400): \ - {\"error\":\"insufficient budget — add credits\"}" - ), - Some(TerminalInferenceFailure::BudgetExhausted) - ); - assert_eq!( - terminal_inference_failure_kind( - "tools_agent failed and did not complete. Error: ollama API error (400 Bad Request): \ - {\"error\":{\"message\":\"\\\"bge-m3:latest\\\" does not support chat\"}}" - ), - Some(TerminalInferenceFailure::ProviderConfig) - ); - for transient in [ - "Error: connection reset by peer", - "Error: 503 Service Unavailable", - "Error: rate limit exceeded, retry after 1s", - "run_code failed and did not complete. Error: timed out", - ] { - assert_eq!( - terminal_inference_failure_kind(transient), - None, - "{transient:?} must NOT classify as a terminal inference failure" - ); - } -} - -#[test] -fn terminal_inference_failure_requires_delegated_inference_envelope() { - // Codex review #3779: the message-only provider classifiers match short - // substrings (`invalid temperature`, `model field is required`, - // `insufficient balance`, …) that can legitimately appear in a RECOVERABLE - // tool's stderr. Those must NOT trip the terminal halt — only a result that - // carries a delegated-inference/provider envelope may. - - // 1) Recoverable `shell`/`run_code` SCRIPT stderr that merely *contains* a - // classifier substring — no provider envelope → must NOT classify, so the - // normal consecutive-failure grace still applies and the script can be - // retried/fixed. - for recoverable in [ - // A Python test raising on a `temperature` arg — not an inference call. - "ValueError: invalid temperature: only 1 is allowed for this model", - // A user script validating config and printing the provider-ish phrase. - "AssertionError: model field is required", - // A finance script echoing an account-balance string. - "RuntimeError: insufficient balance in wallet 0xabc", - // A test asserting on the literal remediation copy. - "FAILED test_models.py::test_unknown - assert 'model_not_found' in resp", - ] { - assert_eq!( - terminal_inference_failure_kind(recoverable), - None, - "recoverable tool stderr without an inference envelope must NOT classify \ - as a terminal inference failure: {recoverable:?}" - ); - } - - // 2) The SAME phrases, but now wrapped in a genuine delegated-inference - // envelope (provider HTTP error / reliable rollup / sub-agent dispatch - // wrapper) → MUST classify, because these only come from a delegated - // provider round-trip. - assert_eq!( - terminal_inference_failure_kind( - "run_code failed and did not complete. Error: custom_openai API error \ - (400 Bad Request): {\"error\":{\"message\":\"invalid temperature: only 1 is \ - allowed for this model\"}}" - ), - Some(TerminalInferenceFailure::ProviderConfig), - "a delegated provider config-rejection (with envelope) must classify" - ); - assert_eq!( - terminal_inference_failure_kind( - "tools_agent failed and did not complete. Error: OpenHuman API error (402): \ - {\"error\":\"Insufficient balance\"}" - ), - Some(TerminalInferenceFailure::BudgetExhausted), - "a delegated budget-exhaustion (with envelope) must classify" - ); - // The reliable-chain rollup envelope (no `API error` token) also qualifies. - assert_eq!( - terminal_inference_failure_kind( - "All providers/models failed. Attempts:\n provider=custom_openai model=gpt-5.5 \ - attempt 1/3: non_retryable; error=insufficient balance" - ), - Some(TerminalInferenceFailure::BudgetExhausted), - "a reliable-chain exhaustion rollup carrying a budget body must classify" - ); -} - -#[test] -fn bare_provider_api_error_in_tool_stderr_does_not_classify() { - // Codex review #3779 (follow-up): a recoverable `shell`/`run_code` task that - // is debugging its OWN API client can print the verbatim provider-HTTP - // envelope shape (` API error (status): …`). Without a - // harness-generated wrapper (`failed and did not complete` / - // `all providers/models failed` / `may not be available on your provider`) - // that output must NOT be treated as a delegated inference failure — else the - // whole turn halts after a single failed command with a misleading "fix your - // model in Settings → AI" message. - for tool_stderr in [ - // The exact shape called out in the review: a config-rejection body that - // a debugging script could print to stdout/stderr. - "OpenAI API error (400): invalid temperature", - "OpenAI API error (400): model field is required", - // Budget-flavoured body, same forged-envelope risk. - "custom_openai API error (402 Payment Required): {\"error\":\"Insufficient balance\"}", - // Responses/streaming envelope variants, still bare (no harness wrapper). - "custom_openai Responses API error: {\"error\":{\"message\":\"model 'x' not found\"}}", - "custom_openai streaming API error (404 Not Found): model 'llama3.3' not found", - ] { - assert_eq!( - terminal_inference_failure_kind(tool_stderr), - None, - "a bare provider-HTTP envelope without a harness wrapper must NOT \ - classify as a terminal inference failure: {tool_stderr:?}" - ); - } -} - -#[test] -fn bare_provider_api_error_keeps_consecutive_failure_grace() { - // The behavioural consequence of the above at the guard level: a `run_code` - // task printing a bare provider API-error body on each attempt must keep its - // normal consecutive-failure grace (it is recoverable user code, not a - // delegated wall), and only trip the 6-in-a-row no-progress backstop — never - // halt on the FIRST failure. - let mut g = RepeatFailureGuard::new(); - let bare = "OpenAI API error (400): invalid temperature"; - for i in 0..(NO_PROGRESS_FAILURE_THRESHOLD - 1) { - assert!( - g.record("run_code", &format!("debug-attempt{i}"), false, bare) - .is_none(), - "a bare provider API-error in tool stderr must NOT halt on failure #{}", - i + 1 - ); - } - // The Nth consecutive failure trips the generic no-progress backstop, as for - // any other recoverable failure — proving the grace was preserved, not that - // the terminal classifier fired. - let halt = g.record( - "run_code", - &format!("debug-attempt{}", NO_PROGRESS_FAILURE_THRESHOLD - 1), - false, - bare, - ); - let msg = halt.expect("the consecutive no-progress backstop still trips"); - assert!( - msg.contains("in a row failed with no progress"), - "must halt via the generic no-progress path, not the terminal-inference \ - path: {msg}" - ); - assert!( - !msg.contains("Settings → AI") && !msg.contains("out of inference budget"), - "must NOT use the terminal-inference remediation copy: {msg}" - ); -} - -#[test] -fn delegated_provider_failure_still_halts_first_after_narrowing() { - // Boundary guard for the #3779 narrowing: a GENUINE delegated provider - // failure — the same config-rejection body, but wrapped by the sub-agent - // dispatch (`failed and did not complete`) — MUST still halt on the first - // occurrence. Narrowing the envelope must not regress the #3104 fix. - let mut g = RepeatFailureGuard::new(); - let delegated = "run_code failed and did not complete — no work was performed. \ - Error: OpenAI API error (400): invalid temperature"; - let halt = g.record("run_code", "{\"prompt\":\"x\"}", false, delegated); - let msg = halt.expect("a wrapped delegated provider-config rejection must halt first"); - assert!(msg.contains("rejected the request"), "got: {msg}"); - assert!( - msg.contains("Settings → AI"), - "actionable remediation: {msg}" - ); -} - -#[test] -fn terminal_budget_failure_halts_on_first_occurrence() { - let mut g = RepeatFailureGuard::new(); - let budget = "run_code failed and did not complete — no work was performed. \ - Error: OpenHuman API error (400): {\"error\":\"Insufficient budget\"}"; - let halt = g.record("run_code", "{\"prompt\":\"write a file\"}", false, budget); - assert!( - halt.is_some(), - "a budget-exhausted delegation must halt on the FIRST failure, not grind to 6" - ); - let msg = halt.unwrap(); - assert!(msg.contains("out of inference budget"), "got: {msg}"); - assert!(msg.contains("`run_code`"), "names the failing step: {msg}"); -} - -#[test] -fn terminal_config_rejection_halts_on_first_occurrence() { - let mut g = RepeatFailureGuard::new(); - let cfg = "tools_agent failed and did not complete. Error: OpenHuman API error \ - (400 Bad Request): Model 'gpt-5.5' is not available. Use GET \ - /openai/v1/models to list available models."; - let halt = g.record("tools_agent", "{\"prompt\":\"do the thing\"}", false, cfg); - assert!( - halt.is_some(), - "a provider-config rejection must halt on the FIRST failure" - ); - let msg = halt.unwrap(); - assert!(msg.contains("rejected the request"), "got: {msg}"); - assert!( - msg.contains("Settings → AI"), - "actionable remediation: {msg}" - ); -} - -#[test] -fn terminal_failure_halts_first_even_across_varied_delegation_tools() { - // The #3104 cascade reproduction at the guard level: the orchestrator - // re-emits a doomed step under VARIED tool names (plan → run_code → - // tools_agent), so neither the identical-(tool,args) repeat guard nor the - // 6-consecutive no-progress guard would catch it in time. The terminal - // classifier must halt on the very first permanent failure regardless of - // which delegation tool surfaced it. - let mut g = RepeatFailureGuard::new(); - // Carries the sub-agent dispatch envelope (`failed and did not complete`) - // so it is recognised as a *delegated* inference failure, not arbitrary - // tool stderr — see `has_inference_failure_envelope`. - let budget = "plan failed and did not complete. Error: {\"error\":\"insufficient balance\"}"; - let halt = g.record("plan", "{\"goal\":\"x\"}", false, budget); - assert!( - halt.is_some(), - "first permanent failure under ANY delegation tool must halt immediately" - ); -} - -#[test] -fn terminal_classifier_does_not_short_circuit_transient_grace() { - // A genuinely transient (retryable) failure must still flow through the - // recoverable-failure headroom — proving the terminal halt is additive, not - // a blanket "halt on first failure" regression. - let mut g = RepeatFailureGuard::new(); - for i in 0..(RECOVERABLE_NO_PROGRESS_FAILURE_THRESHOLD - 1) { - assert!( - g.record( - "run_code", - &format!("attempt{i}"), - false, - "Error: timed out" - ) - .is_none(), - "transient failures must NOT halt before the recoverable no-progress threshold" - ); - } - let halt = g.record( - "run_code", - &format!("attempt{}", RECOVERABLE_NO_PROGRESS_FAILURE_THRESHOLD - 1), - false, - "Error: timed out", - ); - assert!(halt.is_some(), "recoverable failures still remain bounded"); -} - -/// Provider that records the tool-spec names of every `chat()` request -/// it sees, then returns the next scripted response. -struct CapturingProvider { - /// One entry per `chat()` call — the tool-name list extracted from - /// `ChatRequest.tools`. `None` if `tools` was `None`. - captured: Mutex>>>, - responses: Mutex>>, - native_tools: bool, -} - -#[async_trait] -impl Provider for CapturingProvider { - async fn chat_with_system( - &self, - _system_prompt: Option<&str>, - _message: &str, - _model: &str, - _temperature: f64, - ) -> Result { - Ok("fallback".into()) - } - - async fn chat( - &self, - request: ChatRequest<'_>, - _model: &str, - _temperature: f64, - ) -> Result { - let names = request - .tools - .map(|specs| specs.iter().map(|s| s.name.clone()).collect::>()); - self.captured.lock().push(names); - let mut guard = self.responses.lock(); - guard.remove(0) - } - - fn capabilities(&self) -> ProviderCapabilities { - ProviderCapabilities { - native_tool_calling: self.native_tools, - vision: false, - ..ProviderCapabilities::default() - } - } -} - -#[tokio::test] -async fn run_tool_call_loop_dedups_duplicate_tool_names_before_provider_call() { - // Provider returns a single final text response — no tool calls — - // so the loop terminates after exactly one `chat()` invocation, - // and the captured tool list reflects what the fix is supposed to - // guard against (no duplicate names reaching the wire). - let provider = CapturingProvider { - captured: Mutex::new(Vec::new()), - responses: Mutex::new(vec![Ok(ChatResponse { - text: Some("done".into()), - tool_calls: vec![], - usage: None, - reasoning_content: None, - })]), - // Native tool-calling on: only when the provider supports native - // tools does `run_tool_call_loop` populate `ChatRequest.tools`. - native_tools: true, - }; - - // Registry has `EchoTool` (name = "echo"). `extra_tools` adds a - // second tool also named "echo" — the exact collision pattern from - // the bug report (a synthesised delegation tool whose - // `delegate_name` shadows a same-named skill tool). - let registry: Vec> = vec![Box::new(EchoTool)]; - let extra: Vec> = vec![Box::new(EchoTool)]; - - let mut history = vec![ChatMessage::user("hi")]; - let result = run_tool_call_loop( - &provider, - &mut history, - ®istry, - "test-provider", - "model", - 0.0, - true, - "channel", - &crate::openhuman::config::MultimodalConfig::default(), - &crate::openhuman::config::MultimodalFileConfig::default(), - 2, - None, - None, - &extra, - None, - None, - &crate::openhuman::tools::policy::DefaultToolPolicy, - ) - .await - .expect("loop should succeed with deduplicated tool list"); - assert_eq!(result, "done"); - - let captured = provider.captured.lock(); - assert_eq!( - captured.len(), - 1, - "exactly one chat() call expected for a final-only response" - ); - let names = captured[0] - .as_ref() - .expect("native_tools=true should populate ChatRequest.tools"); - let echo_count = names.iter().filter(|n| n.as_str() == "echo").count(); - assert_eq!( - echo_count, 1, - "duplicate tool names must be dropped before the provider call \ - (TAURI-RUST-4) — got names={:?}", - names - ); -} - -// ── End-to-end: agent loop → ApprovalGate → auto_approve short-circuit ── -// -// Exercises the real seam: a scripted LLM emits a tool call for an -// external-effect tool, the loop routes it through the process-global -// `ApprovalGate` (`try_global`), and the tool's presence on the -// `auto_approve` "Always allow" list short-circuits the gate to `Allow` -// *before* parking — so the tool executes without a prompt, even though a -// chat context is present (which would otherwise park it). - -/// A tool with an external side effect, so the loop gates it via the -/// `ApprovalGate`. Records whether `execute` actually ran. -struct ExternalEffectTool { - ran: std::sync::Arc, -} - -#[async_trait] -impl Tool for ExternalEffectTool { - fn name(&self) -> &str { - "ext_effect_e2e_tool" - } - fn description(&self) -> &str { - "external effect (e2e gate test)" - } - fn parameters_schema(&self) -> serde_json::Value { - serde_json::json!({"type":"object"}) - } - fn external_effect_with_args(&self, _args: &serde_json::Value) -> bool { - true - } - async fn execute(&self, _args: serde_json::Value) -> Result { - self.ran.store(true, std::sync::atomic::Ordering::SeqCst); - Ok(ToolResult::success("did-external-effect")) - } -} - -#[tokio::test] -async fn auto_approved_external_effect_tool_runs_through_loop_without_parking() { - use std::sync::atomic::Ordering; - use std::sync::Arc; - // Serialize live-policy / gate global access against the other tests that - // install or reload them (gate auto_approve test, live_policy test, autonomy - // ops tests) — all take this same lock. - let _env = crate::openhuman::config::TEST_ENV_LOCK - .lock() - .unwrap_or_else(|e| e.into_inner()); - - let tool_name = "ext_effect_e2e_tool"; - - // Always-allow the tool via the live policy the gate reads. - let policy = crate::openhuman::security::SecurityPolicy { - auto_approve: vec![tool_name.into()], - ..crate::openhuman::security::SecurityPolicy::default() - }; - crate::openhuman::security::live_policy::install( - Arc::new(policy), - std::env::temp_dir(), - std::env::temp_dir(), - ); - - // Install the process-global gate so the loop's external-effect branch has a - // gate to route through (idempotent; the loop calls `ApprovalGate::try_global`). - let cfg = crate::openhuman::config::Config { - workspace_dir: std::env::temp_dir(), - ..crate::openhuman::config::Config::default() - }; - crate::openhuman::approval::ApprovalGate::init_global(cfg, "session-loop-gate-e2e"); - - let ran = Arc::new(std::sync::atomic::AtomicBool::new(false)); - let provider = ScriptedProvider { - responses: Mutex::new(vec![ - Ok(ChatResponse { - text: Some(format!( - "{{\"name\":\"{tool_name}\",\"arguments\":{{}}}}" - )), - tool_calls: vec![], - usage: None, - reasoning_content: None, - }), - Ok(ChatResponse { - text: Some("done".into()), - tool_calls: vec![], - usage: None, - reasoning_content: None, - }), - ]), - native_tools: false, - vision: false, - }; - let mut history = vec![ChatMessage::user("please act")]; - let tools: Vec> = vec![Box::new(ExternalEffectTool { ran: ran.clone() })]; - - // Run *inside* a chat context: without the allowlist the gate would park - // this external-effect call — so a clean completion proves the auto_approve - // shortcut (checked before chat-context parking) is what let it through. - let result = crate::openhuman::approval::APPROVAL_CHAT_CONTEXT - .scope( - crate::openhuman::approval::ApprovalChatContext { - thread_id: "t-e2e".into(), - client_id: "c-e2e".into(), - }, - run_tool_call_loop( - &provider, - &mut history, - &tools, - "test-provider", - "model", - 0.0, - true, - "channel", - &crate::openhuman::config::MultimodalConfig::default(), - &crate::openhuman::config::MultimodalFileConfig::default(), - 2, - None, - None, - &[], - None, - None, - &crate::openhuman::tools::policy::DefaultToolPolicy, - ), - ) - .await - .expect("loop should complete without parking on an auto-approved tool"); - - assert_eq!(result, "done"); - assert!( - ran.load(Ordering::SeqCst), - "auto-approved external-effect tool must execute (gate must not park it)" - ); -} - -#[test] -fn repeat_output_guard_trips_at_threshold() { - let mut g = RepeatOutputGuard::new(); - // The first THRESHOLD-1 identical signatures must NOT trip. - for _ in 1..REPEAT_OUTPUT_THRESHOLD { - assert!(g.record("same-narration|run_code|{args}").is_none()); - } - // The THRESHOLD-th identical signature trips with a no-progress summary. - let halt = g - .record("same-narration|run_code|{args}") - .expect("identical streak at threshold must trip"); - assert!( - halt.contains("IDENTICAL") || halt.contains("stuck") || halt.contains("progress"), - "halt summary should explain the no-progress loop: {halt}" - ); -} - -#[test] -fn repeat_output_guard_resets_on_changed_signature() { - let mut g = RepeatOutputGuard::new(); - assert!(g.record("a").is_none()); - assert!(g.record("a").is_none()); - // A different signature = real progress; the streak resets. - assert!(g.record("b").is_none()); - // It then takes a FULL fresh streak of the new signature to trip — so - // interleaved/varied work never trips it. - let mut last = None; - for _ in 1..REPEAT_OUTPUT_THRESHOLD { - last = g.record("b"); - } - assert!( - last.is_some(), - "a fresh THRESHOLD-long identical streak should trip after a reset" - ); -} - -#[test] -fn repeat_call_guard_trips_on_identical_batch() { - // The #4095 gap: an identical `(tool, args)` batch repeated back-to-back - // must trip even though each call would "succeed" — the repeat-call guard - // never sees success/failure, only the call signature. - let mut g = RepeatCallGuard::new(); - let mut last = None; - for _ in 0..REPEAT_CALL_THRESHOLD { - last = g.record("list_dir\u{1}{\"path\":\"/app\"}"); - } - let halt = last.expect("identical (tool,args) batch repeated to threshold should trip"); - assert!( - halt.contains("same tool call") && halt.contains("identical arguments"), - "halt summary should name the no-progress repeat: {halt}" - ); -} - -#[test] -fn repeat_call_guard_resets_on_distinct_call() { - // The read -> write -> read pattern: the write is a distinct intervening - // call, so the two reads are NOT back-to-back and must not trip. - let mut g = RepeatCallGuard::new(); - assert!(g.record("read_file\u{1}{\"path\":\"a\"}").is_none()); - assert!(g.record("read_file\u{1}{\"path\":\"a\"}").is_none()); - assert!( - g.record("write_file\u{1}{\"path\":\"a\"}").is_none(), - "a distinct intervening call must reset the streak, not trip" - ); - assert!(g.record("read_file\u{1}{\"path\":\"a\"}").is_none()); - // It then takes a fresh full streak of the original call to trip. - let mut last = None; - for _ in 1..REPEAT_CALL_THRESHOLD { - last = g.record("read_file\u{1}{\"path\":\"a\"}"); - } - assert!( - last.is_some(), - "a fresh THRESHOLD-long identical streak trips after a reset" - ); -} - -#[test] -fn repeat_call_guard_does_not_trip_on_varied_args() { - // Enumeration: same tool, DIFFERENT argument value each time = real - // progress. Distinct signatures keep resetting the streak, so it never trips. - let mut g = RepeatCallGuard::new(); - for path in ["a.txt", "b.txt", "c.txt", "d.txt", "e.txt"] { - assert!( - g.record(&format!("read_file\u{1}{{\"path\":\"{path}\"}}")) - .is_none(), - "varied-arg calls are progress and must never trip" - ); - } -} - -#[test] -fn repeat_call_guard_collapses_reordered_keys() { - // Load-bearing assumption: `Value::to_string()` is key-sorted and - // whitespace-free in this tree (no `preserve_order` feature), so the SAME - // call expressed with reordered JSON keys canonicalizes identically and - // cannot evade the guard. If serde_json's map backing ever changes, the - // first assert fails loudly. - let a = serde_json::json!({"path": "/app", "recursive": false}).to_string(); - let b = serde_json::json!({"recursive": false, "path": "/app"}).to_string(); - assert_eq!( - a, b, - "reordered-key argument JSON must canonicalize identically" - ); - - let mut g = RepeatCallGuard::new(); - let sig_a = format!("read_file\u{1}{a}"); - let sig_b = format!("read_file\u{1}{b}"); - assert!(g.record(&sig_a).is_none()); - // Reordered-key form of the SAME call — treated as identical, streak continues. - assert!(g.record(&sig_b).is_none()); - assert!( - g.record(&sig_a).is_some(), - "key-reordered repeats of the same call must accumulate and trip" - ); -} - -#[test] -fn polling_tools_are_repeat_exempt() { - // wait_subagent is contractually re-invoked with identical args; ordinary - // tools are not exempt. - assert!(is_repeat_call_exempt("wait_subagent")); - assert!(!is_repeat_call_exempt("list_dir")); - assert!(!is_repeat_call_exempt("read_file")); -} - -#[test] -fn repeat_call_guard_reset_clears_streak() { - // reset() (called for an all-poll iteration) drops the streak so a - // legitimate repeated wait never reaches the trip threshold. - let mut g = RepeatCallGuard::new(); - assert!(g.record("wait_subagent\u{1}{}").is_none()); - assert!(g.record("wait_subagent\u{1}{}").is_none()); // streak == 2 - g.reset(); - // Without the reset this 3rd identical call would trip; after reset it is - // only the 1st of a fresh streak. - assert!(g.record("wait_subagent\u{1}{}").is_none()); - assert!(g.record("wait_subagent\u{1}{}").is_none()); -} diff --git a/src/openhuman/agent/library/ops.rs b/src/openhuman/agent/library/ops.rs index 3374a861e..9033b0148 100644 --- a/src/openhuman/agent/library/ops.rs +++ b/src/openhuman/agent/library/ops.rs @@ -158,6 +158,7 @@ mod tests { delegate_name: None, agent_tier: AgentTier::Worker, source: DefinitionSource::Builtin, + graph: Default::default(), } } diff --git a/src/openhuman/agent/stop_hooks.rs b/src/openhuman/agent/stop_hooks.rs index b89df26c2..3407cca12 100644 --- a/src/openhuman/agent/stop_hooks.rs +++ b/src/openhuman/agent/stop_hooks.rs @@ -1,29 +1,24 @@ //! Mid-turn stop hooks — policy-driven halt of an in-flight agent //! turn. //! -//! Distinct from [`super::harness::interrupt::InterruptFence`], which -//! handles user-driven cancellation (Ctrl+C / `/stop`). Stop hooks are -//! the policy lever: budget caps, rate limits, custom kill switches. -//! They run between iterations of the tool-call loop so a runaway -//! turn can be cut short before the next provider call rather than -//! after the fact. +//! Stop hooks are the policy lever: budget caps, rate limits, custom kill +//! switches. They run between iterations of the agent loop so a runaway turn can +//! be cut short before the next provider call rather than after the fact. +//! (User-driven cancellation — Ctrl+C / `/stop` — is handled separately by the +//! `tinyagents` steering/cancellation channel.) //! //! ## Wiring //! -//! Hooks ride on a task-local rather than a parameter on -//! [`crate::openhuman::agent::harness::tool_loop::run_tool_call_loop`] -//! — that signature already takes 16 args and the function is invoked -//! from a dozen+ call sites. The task-local mirrors how -//! [`super::harness::fork_context::PARENT_CONTEXT`] and -//! [`super::harness::sandbox_context::CURRENT_AGENT_SANDBOX_MODE`] are -//! threaded. +//! Hooks ride on a task-local rather than a parameter threaded through the turn, +//! mirroring how [`super::harness::fork_context::PARENT_CONTEXT`] and +//! [`super::harness::sandbox_context::CURRENT_AGENT_SANDBOX_MODE`] are threaded. //! -//! Callers register hooks via [`with_stop_hooks`] around their -//! [`Agent::run_single`] / `run_interactive` invocation; the loop -//! reads them via [`current_stop_hooks`] and fires them at the top of -//! each iteration. A hook returning [`StopDecision::Stop`] aborts the -//! loop with a [`StoppedByHookError`]-shaped `anyhow` error so the -//! caller can surface the reason to the user. +//! Callers register hooks via [`with_stop_hooks`] around their turn invocation. +//! The `tinyagents` adapter snapshots them via [`current_stop_hooks`] and +//! installs a `StopHookMiddleware` +//! ([`crate::openhuman::tinyagents::stop_hooks`]) that fires each hook after +//! every model call; a hook returning [`StopDecision::Stop`] pauses the run +//! gracefully (via the steering handle) before the next provider call. //! //! ## Built-in hooks //! diff --git a/src/openhuman/agent/tests.rs b/src/openhuman/agent/tests.rs index 2805b76ac..cbe49a96d 100644 --- a/src/openhuman/agent/tests.rs +++ b/src/openhuman/agent/tests.rs @@ -836,9 +836,15 @@ async fn turn_preserves_text_alongside_tool_calls() { "Expected non-empty final response after mixed text+tool" ); - // The intermediate text should be in history + // The intermediate text should be preserved in history — either as a + // standalone assistant `Chat` or carried on the `AssistantToolCalls` turn + // that accompanied the tool call (the unified tinyagents representation + // keeps the preface text on the tool-call turn). let has_intermediate = agent.history().iter().any(|msg| match msg { ConversationMessage::Chat(c) => c.role == "assistant" && c.content.contains("Let me check"), + ConversationMessage::AssistantToolCalls { text, .. } => { + text.as_deref().is_some_and(|t| t.contains("Let me check")) + } _ => false, }); assert!(has_intermediate, "Intermediate text should be in history"); diff --git a/src/openhuman/agent_memory/agent/graph.rs b/src/openhuman/agent_memory/agent/graph.rs new file mode 100644 index 000000000..eb1fc93f7 --- /dev/null +++ b/src/openhuman/agent_memory/agent/graph.rs @@ -0,0 +1,13 @@ +//! Turn graph for the `agent_memory` built-in agent. +//! +//! Uses the shared default sub-agent turn graph (`run_subagent_via_graph`) — see +//! [`crate::openhuman::agent::harness::agent_graph`]. Replace the body with +//! `AgentGraph::custom(run)` to give this agent a bespoke tinyagents graph. + +use crate::openhuman::agent::harness::agent_graph::AgentGraph; + +/// Select this agent's turn graph. This is a default agent — it uses the shared +/// default graph rather than defining its own. +pub fn graph() -> AgentGraph { + AgentGraph::Default +} diff --git a/src/openhuman/agent_memory/agent/mod.rs b/src/openhuman/agent_memory/agent/mod.rs index 8bf84783c..cedb79d0e 100644 --- a/src/openhuman/agent_memory/agent/mod.rs +++ b/src/openhuman/agent_memory/agent/mod.rs @@ -1 +1,2 @@ +pub mod graph; pub mod prompt; diff --git a/src/openhuman/agent_orchestration/agent_teams/graph.rs b/src/openhuman/agent_orchestration/agent_teams/graph.rs new file mode 100644 index 000000000..e8baf2ec5 --- /dev/null +++ b/src/openhuman/agent_orchestration/agent_teams/graph.rs @@ -0,0 +1,294 @@ +//! Team-member execution as a conditional-routing `tinyagents` graph (#4249, B2). +//! +//! This is the `agent_teams` folder's `graph.rs` per the per-folder graph +//! convention: the folder's tinyagents graph definition (member-run state +//! machine) lives here; `runtime.rs` drives it. +//! +//! A teammate's live run is a small state machine: **execute** the worker +//! sub-agent, then route on its terminal outcome to **complete** (quality-gate + +//! idle) or **fail** (release + idle + record), joining at a single **done** +//! finish node. Historically this was a hand-rolled `match` in +//! [`super::runtime::drive_member`]; here it is a real `tinyagents` +//! [`CompiledGraph`] with command-routing: +//! +//! ```text +//! ┌─ complete ─┐ +//! execute ──►┤ ├─► done (finish) +//! └─ fail ─────┘ +//! ``` +//! +//! The worker run and the two reconciliation effects are **injected** as +//! closures so the graph mechanics (conditional routing + the engine-error +//! propagation contract) are unit-testable with trivial stubs while production +//! passes the real `spawn_agent`/`wait_agents` + run-ledger writes. +//! +//! Error contract (preserved from the legacy `drive_member`): `run_worker` +//! returns `Err` only for engine-internal failures (spawn/wait) — those +//! propagate out of the graph so the caller releases the task and idles the +//! member. A worker that *ran* but did not complete is a normal `Failed` +//! outcome handled by `on_failed`, which returns `Ok`. + +use std::future::Future; +use std::sync::Arc; + +use tinyagents::graph::export::GraphTopology; +use tinyagents::graph::{ + ClosureStateReducer, Command, CompiledGraph, GraphBuilder, NodeContext, NodeResult, +}; + +use crate::openhuman::tinyagents::observability::GraphTracingSink; + +/// Lift an injected effect's `anyhow` error into the graph's error type so it +/// fails the run (and propagates back out via [`run_member_execution_graph`]). +fn graph_err(e: anyhow::Error) -> tinyagents::TinyAgentsError { + tinyagents::TinyAgentsError::Graph(e.to_string()) +} + +/// Terminal classification of a teammate worker run, produced by the `execute` +/// node and used to route to `complete` or `fail`. +pub(super) enum MemberOutcome { + /// The worker completed; `output` is its result summary (completion + /// evidence). + Completed { output: String }, + /// The worker ran but did not complete (failed / cancelled / closed / + /// defensively, non-terminal); `reason` explains why. + Failed { reason: String }, +} + +/// Typed state threaded through the member graph: carries the routed payload +/// (worker output on the complete path, failure reason on the fail path) so the +/// terminal node can run the matching reconciliation. +#[derive(Clone, Default)] +struct MemberState { + payload: Option, +} + +/// Reducer update emitted by the member graph nodes. +enum MemberUpdate { + /// Store the routed payload (output or reason). + Payload(String), + /// Terminal node fired; no state change. + Noop, +} + +/// Drive a team member's execution on the conditional-routing graph above. +/// +/// Returns `Ok(())` once the member reached a reconciled terminal node, or `Err` +/// when `run_worker` (or a reconciliation closure) failed with an +/// engine-internal error — the caller maps that to release-task + idle-member. +pub(super) async fn run_member_execution_graph( + label: &str, + run_worker: W, + on_complete: C, + on_failed: F, +) -> anyhow::Result<()> +where + W: Fn() -> WF + Clone + Send + Sync + 'static, + WF: Future> + Send + 'static, + C: Fn(String) -> CF + Clone + Send + Sync + 'static, + CF: Future> + Send + 'static, + F: Fn(String) -> FF + Clone + Send + Sync + 'static, + FF: Future> + Send + 'static, +{ + let graph = build_member_graph(run_worker, on_complete, on_failed)? + .with_event_sink(Arc::new(GraphTracingSink::new(label.to_string()))); + + tracing::debug!( + target: "orchestration", + label, + "[orchestration] driving team member execution on tinyagents graph" + ); + + graph + .run(MemberState::default()) + .await + .map_err(|e| anyhow::anyhow!("member graph run failed: {e}"))?; + Ok(()) +} + +/// Build (but do not run) the member-execution `CompiledGraph`. Shared by +/// [`run_member_execution_graph`] and [`member_graph_topology`] so the graph's +/// structure has one definition. +fn build_member_graph( + run_worker: W, + on_complete: C, + on_failed: F, +) -> anyhow::Result> +where + W: Fn() -> WF + Clone + Send + Sync + 'static, + WF: Future> + Send + 'static, + C: Fn(String) -> CF + Clone + Send + Sync + 'static, + CF: Future> + Send + 'static, + F: Fn(String) -> FF + Clone + Send + Sync + 'static, + FF: Future> + Send + 'static, +{ + let mut builder = GraphBuilder::::new().set_reducer( + ClosureStateReducer::new(|mut s: MemberState, u: MemberUpdate| { + if let MemberUpdate::Payload(p) = u { + s.payload = Some(p); + } + Ok(s) + }), + ); + + // `execute`: run the worker, classify its outcome, and route accordingly. + builder = builder.add_node("execute", move |_s: MemberState, _c: NodeContext| { + let run_worker = run_worker.clone(); + async move { + match run_worker().await.map_err(graph_err)? { + MemberOutcome::Completed { output } => Ok(NodeResult::Command( + Command::default() + .with_update(MemberUpdate::Payload(output)) + .with_goto(["complete"]), + )), + MemberOutcome::Failed { reason } => Ok(NodeResult::Command( + Command::default() + .with_update(MemberUpdate::Payload(reason)) + .with_goto(["fail"]), + )), + } + } + }); + + // `complete`: quality-gate + idle via the injected reconciliation. + builder = builder.add_node("complete", move |s: MemberState, _c: NodeContext| { + let on_complete = on_complete.clone(); + async move { + on_complete(s.payload.unwrap_or_default()) + .await + .map_err(graph_err)?; + Ok(NodeResult::Update(MemberUpdate::Noop)) + } + }); + + // `fail`: release + idle + record via the injected reconciliation. + builder = builder.add_node("fail", move |s: MemberState, _c: NodeContext| { + let on_failed = on_failed.clone(); + async move { + on_failed(s.payload.unwrap_or_default()) + .await + .map_err(graph_err)?; + Ok(NodeResult::Update(MemberUpdate::Noop)) + } + }); + + let graph = builder + .add_node("done", |_s: MemberState, _c: NodeContext| async move { + Ok(NodeResult::Update(MemberUpdate::Noop)) + }) + .add_edge("complete", "done") + .add_edge("fail", "done") + .set_entry("execute") + .mark_command_routing("execute") + .set_finish("done") + .compile() + .map_err(|e| anyhow::anyhow!("member graph compile failed: {e}"))?; + Ok(graph) +} + +/// Structure-only [`GraphTopology`] of the member-execution graph for debug / +/// inspection (issue #4249, Phase 4). Built with no-op stub closures — the +/// topology exposes only node names, edges, and routing, never closure bodies. +pub(crate) fn member_graph_topology() -> anyhow::Result { + let graph = build_member_graph( + || async { + Ok(MemberOutcome::Completed { + output: String::new(), + }) + }, + |_: String| async { Ok(()) }, + |_: String| async { Ok(()) }, + )?; + Ok(graph.topology()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicBool, Ordering}; + + #[tokio::test] + async fn completed_outcome_routes_to_complete() { + let completed = Arc::new(AtomicBool::new(false)); + let failed = Arc::new(AtomicBool::new(false)); + let c = completed.clone(); + let f = failed.clone(); + run_member_execution_graph( + "test:complete", + || async { + Ok(MemberOutcome::Completed { + output: "ok".into(), + }) + }, + move |out| { + let c = c.clone(); + async move { + assert_eq!(out, "ok"); + c.store(true, Ordering::SeqCst); + Ok(()) + } + }, + move |_reason| { + let f = f.clone(); + async move { + f.store(true, Ordering::SeqCst); + Ok(()) + } + }, + ) + .await + .expect("graph runs"); + assert!(completed.load(Ordering::SeqCst), "complete path ran"); + assert!(!failed.load(Ordering::SeqCst), "fail path did not run"); + } + + #[tokio::test] + async fn failed_outcome_routes_to_fail() { + let completed = Arc::new(AtomicBool::new(false)); + let failed = Arc::new(AtomicBool::new(false)); + let c = completed.clone(); + let f = failed.clone(); + run_member_execution_graph( + "test:fail", + || async { + Ok(MemberOutcome::Failed { + reason: "boom".into(), + }) + }, + move |_out| { + let c = c.clone(); + async move { + c.store(true, Ordering::SeqCst); + Ok(()) + } + }, + move |reason| { + let f = f.clone(); + async move { + assert_eq!(reason, "boom"); + f.store(true, Ordering::SeqCst); + Ok(()) + } + }, + ) + .await + .expect("graph runs"); + assert!(failed.load(Ordering::SeqCst), "fail path ran"); + assert!( + !completed.load(Ordering::SeqCst), + "complete path did not run" + ); + } + + #[tokio::test] + async fn engine_error_from_worker_propagates() { + let result = run_member_execution_graph( + "test:err", + || async { Err(anyhow::anyhow!("spawn failed")) }, + |_out| async { Ok(()) }, + |_reason| async { Ok(()) }, + ) + .await; + assert!(result.is_err(), "worker engine error propagates out"); + } +} diff --git a/src/openhuman/agent_orchestration/agent_teams/mod.rs b/src/openhuman/agent_orchestration/agent_teams/mod.rs index 0babba9ae..7de046e65 100644 --- a/src/openhuman/agent_orchestration/agent_teams/mod.rs +++ b/src/openhuman/agent_orchestration/agent_teams/mod.rs @@ -22,6 +22,8 @@ //! Namespace note: `agent_team` is distinct from the existing `team` domain, //! which manages backend org/team membership. +mod graph; +pub(crate) use graph::member_graph_topology; pub mod ops; pub mod runtime; mod schemas; diff --git a/src/openhuman/agent_orchestration/agent_teams/runtime.rs b/src/openhuman/agent_orchestration/agent_teams/runtime.rs index 03c54cda4..f547080c6 100644 --- a/src/openhuman/agent_orchestration/agent_teams/runtime.rs +++ b/src/openhuman/agent_orchestration/agent_teams/runtime.rs @@ -237,90 +237,150 @@ async fn drive_member( let prompt = build_member_prompt(task, &delivered); let session = AgentOrchestrationSession::new(format!("team-{team_id}-{member_id}")); - let resp = session - .spawn_agent(SpawnAgentRequest { - agent_id: agent_id.to_string(), - prompt, - model: model_override, - metadata: [ - ("teamId".to_string(), team_id.to_string()), - ("memberId".to_string(), member_id.to_string()), - ("taskId".to_string(), task.id.clone()), - ("teamRunId".to_string(), run_id.to_string()), - ] - .into_iter() - .collect(), - ..Default::default() - }) - .await - .map_err(|e| anyhow!("spawn teammate worker failed: {e}"))?; - let wait = session - .wait_agents(WaitAgentOptions { - orchestration_ids: vec![resp.orchestration_id.clone()], - timeout_ms: None, - }) - .await - .map_err(|e| anyhow!("wait teammate worker failed: {e}"))?; + // ── Worker node effect: spawn the teammate sub-agent, wait for it, and + // classify the terminal outcome. Returns `Err` only for engine-internal + // spawn/wait failures (the caller releases + idles). + let run_worker = { + let session = session.clone(); + let agent_id = agent_id.to_string(); + let team_id = team_id.to_string(); + let member_id = member_id.to_string(); + let task_id = task.id.clone(); + let run_id = run_id.to_string(); + move || { + let session = session.clone(); + let agent_id = agent_id.clone(); + let prompt = prompt.clone(); + let model = model_override.clone(); + let team_id = team_id.clone(); + let member_id = member_id.clone(); + let task_id = task_id.clone(); + let run_id = run_id.clone(); + async move { + let resp = session + .spawn_agent(SpawnAgentRequest { + agent_id, + prompt, + model, + metadata: [ + ("teamId".to_string(), team_id), + ("memberId".to_string(), member_id), + ("taskId".to_string(), task_id), + ("teamRunId".to_string(), run_id), + ] + .into_iter() + .collect(), + ..Default::default() + }) + .await + .map_err(|e| anyhow!("spawn teammate worker failed: {e}"))?; - let snapshot = wait - .agents - .into_iter() - .find(|a| a.orchestration_id == resp.orchestration_id) - .ok_or_else(|| anyhow!("worker snapshot missing after wait"))?; + let wait = session + .wait_agents(WaitAgentOptions { + orchestration_ids: vec![resp.orchestration_id.clone()], + timeout_ms: None, + }) + .await + .map_err(|e| anyhow!("wait teammate worker failed: {e}"))?; - match snapshot.status { - AgentStatus::Completed => { - let output = snapshot.result_summary.unwrap_or_default(); - let evidence = if output.trim().is_empty() { - Vec::new() - } else { - vec![format!( - "run:{run_id} — {}", - truncate_chars(output.trim(), EVIDENCE_MAX_CHARS) - )] - }; - // The teammate's own output is the completion evidence, so the gate - // does not additionally require evidence; it still enforces the - // dependency / claimant invariants. - let outcome = run_ledger::complete_agent_team_task( - config, team_id, &task.id, member_id, &evidence, false, - )?; - log::debug!( - target: LOG_TARGET, - "[agent_team_runtime] drive.completed team={team_id} member={member_id} task={} outcome={outcome:?}", - task.id - ); - run_ledger::mark_agent_team_member_idle(config, team_id, member_id)?; - Ok(()) + let snapshot = wait + .agents + .into_iter() + .find(|a| a.orchestration_id == resp.orchestration_id) + .ok_or_else(|| anyhow!("worker snapshot missing after wait"))?; + + Ok(match snapshot.status { + AgentStatus::Completed => super::graph::MemberOutcome::Completed { + output: snapshot.result_summary.unwrap_or_default(), + }, + AgentStatus::Failed | AgentStatus::Cancelled | AgentStatus::Closed => { + super::graph::MemberOutcome::Failed { + reason: snapshot + .error + .unwrap_or_else(|| "worker ended without completing".to_string()), + } + } + // `wait_agents` with no timeout only returns on terminal + // status, so this is purely defensive — treat as a failure. + other => super::graph::MemberOutcome::Failed { + reason: format!("worker returned non-terminal status {other:?}"), + }, + }) + } } - AgentStatus::Failed | AgentStatus::Cancelled | AgentStatus::Closed => { - let reason = snapshot - .error - .unwrap_or_else(|| "worker ended without completing".to_string()); - log::warn!( - target: LOG_TARGET, - "[agent_team_runtime] drive.worker_failed team={team_id} member={member_id} task={} reason={reason}", - task.id - ); - run_ledger::release_agent_team_task(config, team_id, &task.id)?; - run_ledger::mark_agent_team_member_idle(config, team_id, member_id)?; - record_failure_event(config, team_id, member_id, &task.id, &reason); - Ok(()) + }; + + // ── Complete node effect: the teammate's own output is the completion + // evidence (the gate enforces dependency / claimant invariants but not + // additional evidence), then idle the member. + let on_complete = { + let config = config.clone(); + let team_id = team_id.to_string(); + let member_id = member_id.to_string(); + let task_id = task.id.clone(); + let run_id = run_id.to_string(); + move |output: String| { + let config = config.clone(); + let team_id = team_id.clone(); + let member_id = member_id.clone(); + let task_id = task_id.clone(); + let run_id = run_id.clone(); + async move { + let evidence = if output.trim().is_empty() { + Vec::new() + } else { + vec![format!( + "run:{run_id} — {}", + truncate_chars(output.trim(), EVIDENCE_MAX_CHARS) + )] + }; + let outcome = run_ledger::complete_agent_team_task( + &config, &team_id, &task_id, &member_id, &evidence, false, + )?; + log::debug!( + target: LOG_TARGET, + "[agent_team_runtime] drive.completed team={team_id} member={member_id} task={task_id} outcome={outcome:?}" + ); + run_ledger::mark_agent_team_member_idle(&config, &team_id, &member_id)?; + Ok(()) + } } - other => { - // `wait_agents` with no timeout only returns on terminal status, so - // this is purely defensive. - log::warn!( - target: LOG_TARGET, - "[agent_team_runtime] drive.non_terminal team={team_id} member={member_id} task={} status={other:?}", - task.id - ); - run_ledger::release_agent_team_task(config, team_id, &task.id)?; - run_ledger::mark_agent_team_member_idle(config, team_id, member_id)?; - Ok(()) + }; + + // ── Fail node effect: release the task (so it is reclaimable), idle the + // member, and record a failure event. Returns `Ok` (a ran-but-failed worker + // is a normal terminal outcome, not an engine error). + let on_failed = { + let config = config.clone(); + let team_id = team_id.to_string(); + let member_id = member_id.to_string(); + let task_id = task.id.clone(); + move |reason: String| { + let config = config.clone(); + let team_id = team_id.clone(); + let member_id = member_id.clone(); + let task_id = task_id.clone(); + async move { + log::warn!( + target: LOG_TARGET, + "[agent_team_runtime] drive.worker_failed team={team_id} member={member_id} task={task_id} reason={reason}" + ); + run_ledger::release_agent_team_task(&config, &team_id, &task_id)?; + run_ledger::mark_agent_team_member_idle(&config, &team_id, &member_id)?; + record_failure_event(&config, &team_id, &member_id, &task_id, &reason); + Ok(()) + } } - } + }; + + super::graph::run_member_execution_graph( + &format!("team:{team_id}:{member_id}"), + run_worker, + on_complete, + on_failed, + ) + .await } /// Pick the member's next claimable task: the first (by order) task that is diff --git a/src/openhuman/agent_orchestration/delegation.rs b/src/openhuman/agent_orchestration/delegation.rs new file mode 100644 index 000000000..e8b6aca83 --- /dev/null +++ b/src/openhuman/agent_orchestration/delegation.rs @@ -0,0 +1,165 @@ +//! Production wiring for the multi-stage sub-agent delegation graph (issue +//! #4249, Phase 3). +//! +//! [`tinyagents::delegation::run_delegation`](crate::openhuman::tinyagents::delegation::run_delegation) +//! is the durable plan→execute⇄review→finalize state machine, but it takes an +//! *injected* per-stage worker so its orchestration mechanics can be unit-tested +//! with a mock. This module supplies the **production** worker: every stage runs +//! through [`run_subagent`] (the same dispatch path `spawn_subagent` uses), and +//! the run is made durable/resumable by checkpointing the typed +//! [`DelegationState`] to the openhuman session DB through +//! [`SqlRunLedgerCheckpointer`]. +//! +//! Layering: the delegation *graph* lives in the `tinyagents` adapter seam; this +//! production glue lives in `agent_orchestration`, which already depends on both +//! the seam and `subagent_runner` (so the seam stays free of orchestration deps). + +use std::sync::Arc; + +use crate::openhuman::agent::harness::definition::AgentDefinition; +use crate::openhuman::agent::harness::fork_context::{current_parent, with_parent_context}; +use crate::openhuman::agent::harness::subagent_runner::{run_subagent, SubagentRunOptions}; +use crate::openhuman::agent_orchestration::parent_context::build_root_parent; +use crate::openhuman::config::Config; +use crate::openhuman::tinyagents::delegation::{ + run_delegation, DelegationConfig, DelegationStage, DelegationStageOutput, DelegationState, +}; +use crate::openhuman::tinyagents::SqlRunLedgerCheckpointer; +use tinyagents::graph::checkpoint::Checkpointer; +use tinyagents::CancellationToken; + +const LOG_TARGET: &str = "agent_orchestration::delegation"; + +/// Run the durable plan→execute⇄review→finalize delegation graph for +/// `definition` against `task_prompt`, dispatching every stage to +/// [`run_subagent`] and checkpointing the typed state to the session DB so the +/// run is resumable. Returns the terminal [`DelegationState`] (its +/// `final_output` holds the synthesized answer). +/// +/// Reuses the caller's enclosing agent turn when one is present (e.g. the +/// `delegate` tool). When none is — a controller/background caller — a root +/// parent is built so the nested `run_subagent` calls still resolve a provider, +/// tool registry, memory, and model (mirroring the workflow engine + team +/// runtime). +pub async fn run_subagent_delegation( + config: Arc, + definition: AgentDefinition, + task_prompt: String, + max_revisions: usize, +) -> Result { + let thread_id = format!("delegrun-{}", uuid::Uuid::new_v4()); + let checkpointer: Arc> = + Arc::new(SqlRunLedgerCheckpointer::new(config.clone())); + + tracing::info!( + target: LOG_TARGET, + agent_id = %definition.id, + thread_id = %thread_id, + max_revisions, + "[delegation] starting durable sub-agent delegation" + ); + + let run = async move { + // Re-entrant per-stage worker: clones its captures each call so the graph + // node handler stays `Fn` while each stage dispatches a fresh sub-agent. + let run_stage = move |stage: DelegationStage, state: DelegationState| { + let definition = definition.clone(); + let task = task_prompt.clone(); + async move { + let prompt = build_stage_prompt(stage, &task, &state); + match run_subagent(&definition, &prompt, delegation_subagent_options()).await { + Ok(outcome) => { + let approved = matches!(stage, DelegationStage::Review) + && review_approves(&outcome.output); + Ok(DelegationStageOutput { + text: outcome.output, + approved, + }) + } + Err(e) => Err(format!("delegation stage {stage:?} failed: {e}")), + } + } + }; + + let delegation_config = DelegationConfig { + max_revisions, + checkpointer: Some(checkpointer), + thread_id: Some(thread_id), + cancel: CancellationToken::new(), + }; + run_delegation(delegation_config, run_stage).await + }; + + if current_parent().is_some() { + run.await + } else { + let parent = build_root_parent(&config, "delegation_engine", "delegation", "delegation") + .await + .map_err(|e| format!("delegation: failed to build root parent: {e}"))?; + with_parent_context(parent, run).await + } +} + +/// Per-stage prompt builder: each stage sees the task plus the accumulated state +/// (the plan for `execute`, the latest result for `review`, and the latest +/// reviewer feedback when re-executing after a revision request). +fn build_stage_prompt(stage: DelegationStage, task: &str, state: &DelegationState) -> String { + match stage { + DelegationStage::Plan => format!( + "Produce a short, concrete, numbered plan to accomplish the task below. \ + Reply with the plan only.\n\n[Task]\n{task}" + ), + DelegationStage::Execute => { + let plan = state.plan.as_deref().unwrap_or("(no plan produced)"); + let feedback = state + .reviews + .last() + .map(|r| format!("\n\n[Reviewer feedback to address]\n{r}")) + .unwrap_or_default(); + format!( + "Carry out the plan below for the task and return the completed result.\n\n\ + [Task]\n{task}\n\n[Plan]\n{plan}{feedback}" + ) + } + DelegationStage::Review => { + let result = state + .executions + .last() + .map(String::as_str) + .unwrap_or("(no execution produced)"); + format!( + "Review the result below against the task. If it fully and correctly \ + accomplishes the task, reply with `APPROVE` on the first line. Otherwise \ + reply with `REVISE` on the first line followed by specific, actionable \ + feedback.\n\n[Task]\n{task}\n\n[Result]\n{result}" + ) + } + } +} + +/// A review stage approves when its first line begins with `APPROVE`. +fn review_approves(output: &str) -> bool { + output + .lines() + .next() + .map(|l| l.trim().to_ascii_uppercase()) + .map(|l| l.starts_with("APPROVE")) + .unwrap_or(false) +} + +/// Default sub-agent options for a delegation stage — a fresh UUID task id per +/// call (so retries/revisions don't collide), everything else inherited. +fn delegation_subagent_options() -> SubagentRunOptions { + SubagentRunOptions { + skill_filter_override: None, + toolkit_override: None, + context: None, + model_override: None, + task_id: None, + worker_thread_id: None, + initial_history: None, + checkpoint_dir: None, + worktree_action_dir: None, + run_queue: None, + } +} diff --git a/src/openhuman/agent_orchestration/mod.rs b/src/openhuman/agent_orchestration/mod.rs index cf43dffe6..bd79d2d6d 100644 --- a/src/openhuman/agent_orchestration/mod.rs +++ b/src/openhuman/agent_orchestration/mod.rs @@ -9,6 +9,7 @@ pub mod agent_teams; pub mod background_completions; pub mod background_delivery; pub mod command_center; +pub mod delegation; mod ops; pub(crate) mod parent_context; pub mod run_ledger_finalize; diff --git a/src/openhuman/agent_orchestration/ops_tests.rs b/src/openhuman/agent_orchestration/ops_tests.rs index 589ee17f9..c739bd550 100644 --- a/src/openhuman/agent_orchestration/ops_tests.rs +++ b/src/openhuman/agent_orchestration/ops_tests.rs @@ -13,7 +13,7 @@ use std::sync::{ atomic::{AtomicUsize, Ordering}, Arc, }; -use tokio::time::{sleep, Duration}; +use tokio::time::Duration; #[derive(Default)] struct NoopMemory; @@ -170,12 +170,33 @@ impl Provider for CodingQuestionProvider { } } -#[derive(Default)] +/// Number of parallel sub-agents the parallel-coding test spawns. The provider's +/// synchronization barrier is sized to this so the peak-concurrency assertion is +/// deterministic regardless of scheduler/load. +const PARALLEL_CHILDREN: usize = 3; + struct ParallelState { calls: AtomicUsize, active: AtomicUsize, max_active: AtomicUsize, prompts: Mutex>, + /// Rendezvous point: every child parks here (yielding its worker thread) + /// until all `PARALLEL_CHILDREN` are concurrently inside `chat`, so + /// `max_active` deterministically reaches the peak instead of depending on + /// whether the brief provider calls happen to overlap in wall-clock time. + gate: tokio::sync::Barrier, +} + +impl Default for ParallelState { + fn default() -> Self { + Self { + calls: AtomicUsize::new(0), + active: AtomicUsize::new(0), + max_active: AtomicUsize::new(0), + prompts: Mutex::new(Vec::new()), + gate: tokio::sync::Barrier::new(PARALLEL_CHILDREN), + } + } } #[derive(Clone, Default)] @@ -240,7 +261,11 @@ impl Provider for ParallelCodingProvider { self.state.calls.fetch_add(1, Ordering::SeqCst); let current = self.state.active.fetch_add(1, Ordering::SeqCst) + 1; self.record_peak(current); - sleep(Duration::from_millis(35)).await; + // Park until all children have entered `chat` (or a generous timeout, so + // an unexpected missing child fails the assertion fast rather than + // hanging). Once released, every child was concurrently active, so the + // recorded peak equals `PARALLEL_CHILDREN`. + let _ = tokio::time::timeout(Duration::from_secs(10), self.state.gate.wait()).await; let flattened = request .messages @@ -303,10 +328,15 @@ async fn e2e_orchestrator_answers_coding_agent_question_and_resumes_child() { .await .expect("spawn coding agent"); + // These waits spawn a *real* builtin (`code_executor`) sub-agent on the + // detached executor, which builds the full agent (prompt assembly, tool + // resolution, registry) before the mock provider returns — ~2.7s per child. + // The wait budget must clear that with CI headroom; a tight 2s expires first + // and reports the child as `Running`. let first_wait = session .wait_agents(WaitAgentOptions { orchestration_ids: vec![first.orchestration_id.clone()], - timeout_ms: Some(2_000), + timeout_ms: Some(15_000), }) .await .expect("wait first child"); @@ -347,7 +377,7 @@ async fn e2e_orchestrator_answers_coding_agent_question_and_resumes_child() { let final_wait = session .wait_agents(WaitAgentOptions { orchestration_ids: vec![follow_up.orchestration_id.clone()], - timeout_ms: Some(2_000), + timeout_ms: Some(15_000), }) .await .expect("wait follow-up"); @@ -369,7 +399,13 @@ async fn e2e_orchestrator_answers_coding_agent_question_and_resumes_child() { assert!(prompts[1].contains("ORCH_ANSWER_USE_RPC")); } -#[tokio::test] +// Multi-thread runtime: this test asserts the three detached sub-agents run +// *concurrently* (`max_active >= 2`). Each child does a CPU-bound builtin-agent +// build before its (mock) provider call; on a single-threaded runtime those +// builds serialize, so the brief provider calls never overlap and the peak- +// concurrency assertion flakes under load. Real worker threads let the builds — +// and therefore the provider calls — actually overlap. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn e2e_orchestrator_waits_for_multiple_parallel_coding_subagents() { AgentDefinitionRegistry::init_global_builtins().unwrap(); let provider = ParallelCodingProvider::default(); @@ -413,7 +449,7 @@ async fn e2e_orchestrator_waits_for_multiple_parallel_coding_subagents() { let waited = session .wait_agents(WaitAgentOptions { orchestration_ids: spawned, - timeout_ms: Some(2_000), + timeout_ms: Some(15_000), }) .await .expect("wait parallel children"); diff --git a/src/openhuman/agent_orchestration/running_subagents.rs b/src/openhuman/agent_orchestration/running_subagents.rs index 391a3b7d3..22a54d42c 100644 --- a/src/openhuman/agent_orchestration/running_subagents.rs +++ b/src/openhuman/agent_orchestration/running_subagents.rs @@ -17,6 +17,18 @@ //! and swept on `register` only once the table passes a soft cap, so it can't //! grow unbounded if a parent never waits (the Codex "spawn-slot leak" failure //! mode — openai/codex#18335). +//! +//! ## Typed lifecycle ledger (issue #4249) +//! +//! Alongside the executor plumbing (abort handle + steering queue + watch +//! status), every detached sub-agent is also recorded in a process-wide +//! [`tinyagents` orchestration `TaskStore`](crate::openhuman::tinyagents::orchestration) +//! as an `OrchestrationTaskKind::SubAgent` task. `register` inserts it +//! (`Pending` → `Running`) and spawns a watcher that mirrors the child's +//! terminal status into the store (`Completed`/`Failed`/`Awaiting`); the cancel +//! paths record `Cancelled`. This gives a typed, queryable lifecycle +//! (`task_records`) without disturbing the watch/abort/steer machinery the store +//! does not cover. use std::collections::{HashMap, HashSet}; use std::path::PathBuf; @@ -27,6 +39,73 @@ use tokio::sync::watch; use tokio::task::AbortHandle; use crate::openhuman::agent::harness::run_queue::{QueueMode, QueuedMessage, RunQueue}; +use crate::openhuman::tinyagents::orchestration::{ + InMemoryTaskStore, OrchestrationTaskFilter, OrchestrationTaskKind, OrchestrationTaskRecord, + OrchestrationTaskResult, OrchestrationTaskSpec, TaskStore, +}; +use tinyagents::harness::ids::TaskId; + +/// Process-wide typed lifecycle ledger for detached sub-agents (issue #4249). +fn task_store() -> &'static InMemoryTaskStore { + static STORE: OnceLock = OnceLock::new(); + STORE.get_or_init(InMemoryTaskStore::new) +} + +/// Record a freshly-spawned sub-agent in the store (`Pending` → `Running`). +/// Insert errors (e.g. a re-used task id across tests) are intentionally ignored. +fn record_spawned(task_id: &str, agent_id: &str, parent_session: &str) { + let store = task_store(); + let spec = OrchestrationTaskSpec::new( + task_id.to_string(), + OrchestrationTaskKind::SubAgent { + agent: agent_id.to_string(), + }, + ) + .with_metadata("parentSession", parent_session.to_string()); + let _ = store.insert(spec); + let _ = store.mark_running(&TaskId::new(task_id)); +} + +/// Mirror a child's published [`SubagentStatus`] into the typed store. Transition +/// errors (already terminal / cancelled) are ignored — first writer wins. +fn record_status(task_id: &str, status: &SubagentStatus) { + let store = task_store(); + let id = TaskId::new(task_id); + match status { + SubagentStatus::Completed { output, .. } => { + let _ = store.complete(&id, OrchestrationTaskResult::text(output.clone())); + } + SubagentStatus::Failed { error } => { + let _ = store.fail(&id, error.clone()); + } + SubagentStatus::AwaitingUser { .. } => { + let _ = store.mark_awaiting(&id); + } + SubagentStatus::Running => {} + } +} + +/// Record a cancellation (`CancelRequested` → `Cancelled`) for `task_id`. +fn record_cancelled(task_id: &str) { + let store = task_store(); + let id = TaskId::new(task_id); + let _ = store.request_cancel(&id); + let _ = store.mark_cancelled(&id); +} + +/// Snapshot the typed lifecycle records, optionally scoped to a `parent_session`. +/// Backs typed status surfaces without touching the live registry's executor +/// plumbing. +pub fn task_records(parent_session: Option<&str>) -> Vec { + let all = task_store().list(OrchestrationTaskFilter::default()); + match parent_session { + Some(ps) => all + .into_iter() + .filter(|r| r.spec.metadata.get("parentSession").map(String::as_str) == Some(ps)) + .collect(), + None => all, + } +} /// Terminal/transient state of a running async sub-agent, published by the /// spawner's background task and observed by `wait_subagent`. @@ -110,6 +189,12 @@ pub fn register( abort: AbortHandle, status: watch::Receiver, ) { + // Typed lifecycle ledger: record the spawn and mirror the child's terminal + // status into the store via a lightweight watcher (issue #4249). Done before + // the entry is moved into the map so the metadata is still in scope. + record_spawned(&task_id, &agent_id, &parent_session); + spawn_status_watcher(task_id.clone(), status.clone()); + let entry = RunningSubagentEntry { agent_id, parent_session, @@ -135,6 +220,30 @@ pub fn register( ); } +/// Watch a child's status channel and mirror the first terminal status into the +/// typed lifecycle store. A dropped sender (aborted/panicked task) without a +/// terminal status is recorded as a failure, matching [`wait`]. +fn spawn_status_watcher(task_id: String, mut status: watch::Receiver) { + tokio::spawn(async move { + loop { + let snapshot = status.borrow_and_update().clone(); + if snapshot.is_terminal() { + record_status(&task_id, &snapshot); + break; + } + if status.changed().await.is_err() { + record_status( + &task_id, + &SubagentStatus::Failed { + error: "sub-agent task ended without reporting a result".to_string(), + }, + ); + break; + } + } + }); +} + /// Resolve a durable `subagent_session_id` to the currently-running transient /// `task_id`, enforcing parent-session ownership. pub fn task_id_for_session( @@ -357,6 +466,7 @@ pub fn cancel_by_task(task_id: &str) -> Option { let mut map = registry().lock().expect("running_subagents mutex poisoned"); let entry = map.remove(task_id)?; entry.abort.abort(); + record_cancelled(task_id); log::debug!( "[running_subagents] cancel_by_task task_id={} agent_id={} parent_thread_id={:?} live_entries={}", task_id, @@ -389,6 +499,7 @@ pub fn close(task_id: &str, parent_session: &str) -> bool { Some(entry) if entry.parent_session == parent_session => { entry.abort.abort(); map.remove(task_id); + record_cancelled(task_id); true } _ => false, @@ -409,6 +520,7 @@ pub fn cancel_for_thread(thread_id: &str) -> usize { for id in &to_cancel { if let Some(entry) = map.remove(id) { entry.abort.abort(); + record_cancelled(id); } } let count = to_cancel.len(); @@ -432,8 +544,9 @@ pub fn cancel_all() -> Vec { let count = map.len(); let mut thread_ids: Vec = Vec::new(); let mut seen: HashSet = HashSet::new(); - for (_, entry) in map.drain() { + for (task_id, entry) in map.drain() { entry.abort.abort(); + record_cancelled(&task_id); if let Some(thread_id) = entry.parent_thread_id { if seen.insert(thread_id.clone()) { thread_ids.push(thread_id); @@ -465,6 +578,7 @@ fn now_ms() -> u64 { #[cfg(test)] mod tests { use super::*; + use crate::openhuman::tinyagents::orchestration::OrchestrationTaskStatus; use std::sync::MutexGuard; /// Serializes every test that touches the global [`REGISTRY`]. We reuse the @@ -516,6 +630,55 @@ mod tests { tx } + #[tokio::test] + async fn task_store_records_spawn_complete_and_cancel() { + let _guard = test_guard(); + // Spawn → the ledger sees a running SubAgent task scoped to the parent. + let tx = register_test("task-ledger-1", "ledger-parent", RunQueue::new()); + let running = task_records(Some("ledger-parent")); + assert!( + running + .iter() + .any(|r| r.spec.task_id.as_str() == "task-ledger-1" + && r.status == OrchestrationTaskStatus::Running), + "spawned sub-agent is recorded Running: {running:?}" + ); + + // Publish a terminal status → the watcher mirrors Completed into the store. + tx.send(SubagentStatus::Completed { + output: "done".into(), + iterations: 2, + }) + .unwrap(); + // Let the watcher task observe the change. + for _ in 0..50 { + tokio::task::yield_now().await; + if task_records(None) + .iter() + .any(|r| r.spec.task_id.as_str() == "task-ledger-1" && r.is_terminal()) + { + break; + } + } + let after = task_records(None); + let rec = after + .iter() + .find(|r| r.spec.task_id.as_str() == "task-ledger-1") + .expect("ledger record present"); + assert_eq!(rec.status, OrchestrationTaskStatus::Completed); + + // A second sub-agent that gets cancelled is recorded Cancelled. + let _tx2 = register_test("task-ledger-2", "ledger-parent", RunQueue::new()); + assert!(cancel_by_task("task-ledger-2").is_some()); + let cancelled = task_records(None) + .into_iter() + .find(|r| r.spec.task_id.as_str() == "task-ledger-2") + .expect("cancelled record present"); + assert_eq!(cancelled.status, OrchestrationTaskStatus::Cancelled); + + prune("task-ledger-1"); + } + #[tokio::test] async fn task_id_for_session_enforces_parent_ownership() { let _guard = test_guard(); diff --git a/src/openhuman/agent_orchestration/tools.rs b/src/openhuman/agent_orchestration/tools.rs index 9ce0ff17f..d592981b1 100644 --- a/src/openhuman/agent_orchestration/tools.rs +++ b/src/openhuman/agent_orchestration/tools.rs @@ -8,6 +8,8 @@ mod awaiting_user; mod close_subagent; #[path = "tools/continue_subagent.rs"] mod continue_subagent; +#[path = "tools/delegate_graph.rs"] +mod delegate_graph; #[path = "tools/dispatch.rs"] mod dispatch; #[path = "tools/list_subagents.rs"] @@ -42,6 +44,7 @@ pub use agent_prepare_context::{ pub use archetype_delegation::ArchetypeDelegationTool; pub use close_subagent::CloseSubagentTool; pub use continue_subagent::ContinueSubagentTool; +pub use delegate_graph::DelegateGraphTool; pub use list_subagents::ListSubagentsTool; pub use skill_delegation::{SkillDelegationTool, INTEGRATIONS_DELEGATE_TOOL_NAME}; pub use spawn_async_subagent::SpawnAsyncSubagentTool; diff --git a/src/openhuman/agent_orchestration/tools/delegate_graph.rs b/src/openhuman/agent_orchestration/tools/delegate_graph.rs new file mode 100644 index 000000000..6cca36f62 --- /dev/null +++ b/src/openhuman/agent_orchestration/tools/delegate_graph.rs @@ -0,0 +1,164 @@ +//! Tool: `delegate` — run a multi-stage, durable sub-agent delegation. +//! +//! Where `spawn_subagent` hands a sub-task to a single sub-agent turn, `delegate` +//! drives the durable plan→execute⇄review→finalize graph +//! ([`agent_orchestration::delegation::run_subagent_delegation`](crate::openhuman::agent_orchestration::delegation::run_subagent_delegation)): +//! the chosen agent first plans, then executes, then reviews its own work and +//! revises up to `max_revisions` times before finalizing. The graph checkpoints +//! its typed state to the session DB, so a crashed or paused run is resumable. +//! +//! Use it for non-trivial sub-tasks that benefit from a self-review/revision +//! loop; use `spawn_subagent` for a single focused hand-off. + +use crate::openhuman::agent::harness::definition::AgentDefinitionRegistry; +use crate::openhuman::agent_orchestration::delegation::run_subagent_delegation; +use crate::openhuman::config::Config; +use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolResult}; +use async_trait::async_trait; +use serde_json::json; +use std::sync::Arc; + +/// Default reviewer-requested revision budget when the caller omits it. +const DEFAULT_MAX_REVISIONS: usize = 2; +/// Hard ceiling so a caller can't request an unbounded execute⇄review loop. +const MAX_MAX_REVISIONS: usize = 5; + +/// Runs the durable multi-stage delegation graph for a chosen sub-agent. +pub struct DelegateGraphTool; + +impl Default for DelegateGraphTool { + fn default() -> Self { + Self::new() + } +} + +impl DelegateGraphTool { + pub fn new() -> Self { + Self + } +} + +#[async_trait] +impl Tool for DelegateGraphTool { + fn name(&self) -> &str { + // Distinct from the config-driven `delegate` tool (`DelegateTool`, added + // when `[agents]` are configured) so the two don't collide in the + // first-match tool registry — a shared `delegate` name made whichever + // registered first shadow the other. This is the graph delegation path. + "delegate_graph" + } + + fn description(&self) -> &str { + "Delegate a non-trivial sub-task to a specialised sub-agent that PLANS, \ + EXECUTES, then REVIEWS and revises its own work before returning a final \ + result (a durable plan→execute→review→finalize loop). Prefer this over \ + `spawn_subagent` when the sub-task benefits from a self-review/revision \ + pass. Provide `agent_id` and a complete, self-contained `task` (the \ + sub-agent has no memory of this conversation)." + } + + fn parameters_schema(&self) -> serde_json::Value { + let agent_ids: Vec = AgentDefinitionRegistry::global() + .map(|reg| reg.list().iter().map(|d| d.id.clone()).collect()) + .unwrap_or_default(); + + let agent_id_schema = if agent_ids.is_empty() { + json!({ + "type": "string", + "description": "Sub-agent id (e.g. code_executor, researcher, critic)." + }) + } else { + json!({ + "type": "string", + "enum": agent_ids, + "description": "Sub-agent id from the registry." + }) + }; + + json!({ + "type": "object", + "required": ["agent_id", "task"], + "properties": { + "agent_id": agent_id_schema, + "task": { + "type": "string", + "description": "Complete, self-contained description of the task. Include all context the sub-agent needs — it cannot see this conversation." + }, + "max_revisions": { + "type": "integer", + "minimum": 0, + "maximum": MAX_MAX_REVISIONS, + "description": "Maximum reviewer-requested revisions before finalizing (default 2)." + } + } + }) + } + + fn permission_level(&self) -> PermissionLevel { + PermissionLevel::Execute + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + let agent_id = match args.get("agent_id").and_then(|v| v.as_str()) { + Some(s) if !s.trim().is_empty() => s.trim().to_string(), + _ => return Ok(ToolResult::error("delegate: `agent_id` is required.")), + }; + let task = match args.get("task").and_then(|v| v.as_str()) { + Some(s) if !s.trim().is_empty() => s.to_string(), + _ => return Ok(ToolResult::error("delegate: `task` is required.")), + }; + let max_revisions = args + .get("max_revisions") + .and_then(|v| v.as_u64()) + .map(|n| (n as usize).min(MAX_MAX_REVISIONS)) + .unwrap_or(DEFAULT_MAX_REVISIONS); + + let registry = match AgentDefinitionRegistry::global() { + Some(reg) => reg, + None => { + return Ok(ToolResult::error( + "delegate: agent definition registry not initialized.", + )) + } + }; + let definition = match registry.get(&agent_id) { + Some(def) => def.clone(), + None => { + return Ok(ToolResult::error(format!( + "delegate: agent definition '{agent_id}' not found in registry." + ))) + } + }; + + let config = match Config::load_or_init().await { + Ok(cfg) => Arc::new(cfg), + Err(e) => { + return Ok(ToolResult::error(format!( + "delegate: failed to load config: {e}" + ))) + } + }; + + match run_subagent_delegation(config, definition, task, max_revisions).await { + Ok(state) => { + let final_output = state + .final_output + .unwrap_or_else(|| "(delegation produced no final output)".to_string()); + let note = if state.cancelled { + " (cancelled)" + } else if state.revisions > 0 { + " (after revision)" + } else { + "" + }; + Ok(ToolResult::success(format!( + "[Delegated to {agent_id}{note}, {} review pass(es)]\n{final_output}", + state.reviews.len() + ))) + } + Err(e) => Ok(ToolResult::error(format!( + "delegate failed for '{agent_id}': {e}" + ))), + } + } +} diff --git a/src/openhuman/agent_orchestration/tools/spawn_parallel_agents.rs b/src/openhuman/agent_orchestration/tools/spawn_parallel_agents.rs index bd5f01b1f..16ae7d112 100644 --- a/src/openhuman/agent_orchestration/tools/spawn_parallel_agents.rs +++ b/src/openhuman/agent_orchestration/tools/spawn_parallel_agents.rs @@ -8,7 +8,6 @@ use crate::openhuman::agent::progress::AgentProgress; use crate::openhuman::file_state; use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolResult}; use async_trait::async_trait; -use futures::future::join_all; use serde::{Deserialize, Serialize}; use serde_json::json; @@ -51,7 +50,7 @@ struct ParallelAgentTask { base_ref: Option, } -#[derive(Debug, Serialize)] +#[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] struct ParallelAgentResult { task_id: String, @@ -460,25 +459,38 @@ impl Tool for SpawnParallelAgentsTool { "[spawn_parallel_agents] prepared_tasks" ); - let futures = - prepared - .into_iter() - .map(|(definition, prompt, task, task_id, worktree_path)| { - let repo_root = action_root.clone(); - async move { - run_one_parallel_task( - definition, - prompt, - task, - task_id, - worktree_path, - repo_root, - ) - .await - } - }); + // Fan the prepared workers out on the tinyagents graph (dispatch -> + // parallel worker nodes -> collect barrier) instead of a hand-rolled + // `join_all`. Results come back in prepared order, then we append them + // after the immediate (pre-flight failed) ones — the same ordering as + // before. `max_concurrency` = worker count preserves the prior + // all-at-once behaviour. + let max_concurrency = prepared.len().max(1); + let action_root_for_workers = action_root.clone(); + let fanned = crate::openhuman::tinyagents::orchestration::run_parallel_fanout( + "spawn_parallel_agents", + prepared, + max_concurrency, + move |_i, (definition, prompt, task, task_id, worktree_path)| { + let repo_root = action_root_for_workers.clone(); + async move { + run_one_parallel_task( + definition, + prompt, + task, + task_id, + worktree_path, + repo_root, + ) + .await + } + }, + ) + .await + .map_err(|e| anyhow::anyhow!(e))?; + let mut results = immediate_results; - for result in join_all(futures).await { + for result in fanned { match &result { ParallelAgentResult { success: true, diff --git a/src/openhuman/agent_orchestration/workflow_runs/engine.rs b/src/openhuman/agent_orchestration/workflow_runs/engine.rs index 30dec184e..61f448e03 100644 --- a/src/openhuman/agent_orchestration/workflow_runs/engine.rs +++ b/src/openhuman/agent_orchestration/workflow_runs/engine.rs @@ -52,6 +52,19 @@ const PHASE_RUNNING: &str = "running"; const PHASE_COMPLETED: &str = "completed"; const PHASE_FAILED: &str = "failed"; +/// One worker's outcome from the intra-phase graph fan-out (see +/// [`drive_phases`]). Rides in the fan-out graph's typed state, so it is `Clone`. +#[derive(Clone)] +struct PhaseWorkerOutcome { + /// The spawned child's orchestration id, recorded in `child_run_ids`. + /// `None` when the child was never spawned (cancelled / spawn error). + orchestration_id: Option, + /// Completed child's output row appended to the phase outputs. + output: Option, + /// Failure reason when the worker did not complete successfully. + error: Option, +} + // ─────────────────────────────────────────────────────────────────────────── // Cancellation registry // ─────────────────────────────────────────────────────────────────────────── @@ -290,7 +303,7 @@ async fn run_engine_loop(config: &Config, run_id: &str, definition: WorkflowDefi let outcome = match build_root_parent(config, "workflow_engine", "workflow", "workflow").await { Ok(parent) => { with_parent_context(parent, async { - drive_phases(config, run_id, &definition, &cancel).await + super::graph::drive_phases(config, run_id, &definition, &cancel).await }) .await } @@ -327,53 +340,310 @@ async fn run_engine_loop(config: &Config, run_id: &str, definition: WorkflowDefi clear_cancel_flag(run_id); } -/// Topologically walk the phase DAG, executing each phase whose dependencies -/// are all `completed`. Persists after every phase. Returns `Ok(())` once every -/// phase is completed, the run is interrupted, or a phase fails (the terminal -/// status is written to the row before returning `Ok`). Returns `Err` only for -/// engine-internal failures (e.g. ledger write errors). -async fn drive_phases( +/// What the scheduler's `dispatch` step decided. +pub(super) enum PhaseSelection { + /// Execute this phase next. + Run(WorkflowPhase), + /// The run reached a terminal status (already persisted) — route to `done`. + Terminated, +} + +/// Outcome of executing one phase in the `run_phase` step. +pub(super) enum PhaseExecOutcome { + /// The phase completed; `spawned` children were launched (added to the + /// run-wide `max_children` tally). Route back to `dispatch`. + Continue { spawned: u32 }, + /// The run reached a terminal status (already persisted) — route to `done`. + Terminated, +} + +/// `dispatch` step: reload the run, honour cancellation, and pick the next +/// runnable phase (pending, all deps `completed`). When none remains, persist the +/// terminal status (Completed / Failed) and return [`PhaseSelection::Terminated`]. +pub(super) async fn select_next_phase( config: &Config, run_id: &str, definition: &WorkflowDefinition, cancel: &Arc, -) -> Result<()> { - use crate::openhuman::agent_orchestration::{ - AgentOrchestrationSession, AgentStatus, SpawnAgentRequest, WaitAgentOptions, - }; + session: &crate::openhuman::agent_orchestration::AgentOrchestrationSession, +) -> Result { + // Reload so we read the latest phase_states (and a resume picks up persisted + // progress). + let run = get_workflow_run(config, run_id)? + .ok_or_else(|| anyhow!("workflow run {run_id} vanished mid-loop"))?; + let phase_states = run.phase_states.clone(); + let child_run_ids = run.child_run_ids.clone(); - let session = AgentOrchestrationSession::new(format!("workflow-engine-{run_id}")); - let mut total_spawned: u32 = 0; + // Cancellation check between phases. + if cancel.load(Ordering::SeqCst) { + log::debug!( + target: LOG_TARGET, + "[workflow_run_engine] loop.cancelled run={run_id}" + ); + session.abort_all().await; + persist( + config, + &run, + phase_states, + child_run_ids, + WorkflowRunStatus::Interrupted, + None, + false, + )?; + return Ok(PhaseSelection::Terminated); + } - // Optional per-run model override. When present in the run input - // (`{"modelOverride": "..."}`), every child is forced onto the parent - // (root) provider with this model instead of its declarative workload - // provider. Production runs omit it (agents use their configured provider); - // deterministic mock-backend tests set it so children resolve to the - // injected mock provider. - let model_override = get_workflow_run(config, run_id)? - .and_then(|r| { - r.input - .get("modelOverride") - .and_then(Value::as_str) - .map(str::to_string) - }) - .filter(|m| !m.trim().is_empty()); - - loop { - // Reload the run each iteration so we read the latest phase_states (and - // so a resume picks up persisted progress). - let run = get_workflow_run(config, run_id)? - .ok_or_else(|| anyhow!("workflow run {run_id} vanished mid-loop"))?; - let mut phase_states = run.phase_states.clone(); - let mut child_run_ids = run.child_run_ids.clone(); - - // Cancellation check between phases. - if cancel.load(Ordering::SeqCst) { + // Find the next runnable phase: pending, with all deps completed. + let Some(phase) = next_runnable_phase(definition, &phase_states) else { + // No runnable phase left. Either everything is done, or we're blocked + // (which a validated DAG shouldn't be). + if all_phases_completed(definition, &phase_states) { + let summary = synthesize_summary(definition, &phase_states); log::debug!( target: LOG_TARGET, - "[workflow_run_engine] loop.cancelled run={run_id}" + "[workflow_run_engine] loop.completed run={run_id} summary_chars={}", + summary.as_deref().map(str::len).unwrap_or(0) ); + persist( + config, + &run, + phase_states, + child_run_ids, + WorkflowRunStatus::Completed, + summary, + true, + )?; + } else { + log::warn!( + target: LOG_TARGET, + "[workflow_run_engine] loop.stuck run={run_id} no_runnable_phase" + ); + persist( + config, + &run, + phase_states, + child_run_ids, + WorkflowRunStatus::Failed, + Some("no runnable phase (dependency deadlock)".to_string()), + true, + )?; + } + return Ok(PhaseSelection::Terminated); + }; + + Ok(PhaseSelection::Run(phase.clone())) +} + +/// `run_phase` step: mark the phase running, fan its agents out on the +/// intra-phase tinyagents graph (bounded by `default_concurrency`, capped by the +/// run-wide `max_children` budget), aggregate outcomes, and persist the new +/// phase state. Returns [`PhaseExecOutcome::Continue`] with the number of +/// children spawned, or [`PhaseExecOutcome::Terminated`] when the phase failed or +/// cancellation landed mid-phase (the terminal status is persisted first). +#[allow(clippy::too_many_arguments)] +pub(super) async fn execute_phase( + config: &Config, + run_id: &str, + definition: &WorkflowDefinition, + session: &crate::openhuman::agent_orchestration::AgentOrchestrationSession, + cancel: &Arc, + model_override: Option, + phase: &WorkflowPhase, + total_spawned: u32, +) -> Result { + use crate::openhuman::agent_orchestration::{AgentStatus, SpawnAgentRequest, WaitAgentOptions}; + + // Reload so the phase state we mutate + persist is the latest projection. + let run = get_workflow_run(config, run_id)? + .ok_or_else(|| anyhow!("workflow run {run_id} vanished mid-phase"))?; + let mut phase_states = run.phase_states.clone(); + let mut child_run_ids = run.child_run_ids.clone(); + // Children launched *this* phase (the reducer delta added to `total_spawned`). + let mut spawned_this_phase: u32 = 0; + + log::debug!( + target: LOG_TARGET, + "[workflow_run_engine] phase.start run={run_id} phase={} agents={} spawned_so_far={}", + phase.name, + phase.agent_ids.len(), + total_spawned + ); + set_phase_status(&mut phase_states, &phase.name, PHASE_RUNNING, None); + persist( + config, + &run, + phase_states.clone(), + child_run_ids.clone(), + WorkflowRunStatus::Running, + None, + false, + )?; + + // Thread prior phases' outputs into this phase's prompt context. + let upstream_context = upstream_outputs(definition, phase, &phase_states); + + // Run the phase's agents on a tinyagents graph fan-out (dispatch -> + // parallel worker nodes bounded by `default_concurrency` -> collect + // barrier), never exceeding the run-wide `max_children` cap. Each worker + // spawns one child and waits for its terminal status; outcomes return in + // phase order. + let mut phase_outputs: Vec = Vec::new(); + let mut phase_failed: Option = None; + + let concurrency = definition.default_concurrency.max(1) as usize; + let budget_left = definition.max_children.saturating_sub(total_spawned); + + if budget_left == 0 { + phase_failed = Some(format!( + "max_children cap ({}) reached before phase '{}' completed", + definition.max_children, phase.name + )); + } else { + // Cap this phase to the run-wide `max_children` budget; if the phase + // needs more workers than the budget allows we run as many as fit + // and then fail with the cap message (matching the legacy loop). + let capacity = budget_left as usize; + let phase_agents = phase.agent_ids.to_vec(); + let capped = phase_agents.len() > capacity; + let to_run: Vec<(usize, String)> = phase_agents + .into_iter() + .take(capacity) + .enumerate() + .collect(); + + // Clones moved into the (`'static`) worker closure. + let session_for_workers = session.clone(); + let cancel_for_workers = cancel.clone(); + let run_input = run.input.clone(); + let phase_owned = phase.clone(); + let upstream_owned = upstream_context.clone(); + let model_for_workers = model_override.clone(); + + let outcomes = crate::openhuman::tinyagents::orchestration::run_parallel_fanout( + &format!("workflow:{run_id}:{}", phase_owned.name), + to_run, + concurrency, + move |_node, (agent_index, agent_id)| { + let session = session_for_workers.clone(); + let cancel = cancel_for_workers.clone(); + let run_input = run_input.clone(); + let phase = phase_owned.clone(); + let upstream = upstream_owned.clone(); + let model = model_for_workers.clone(); + async move { + // Don't launch new children once cancellation has landed. + if cancel.load(Ordering::SeqCst) { + return PhaseWorkerOutcome { + orchestration_id: None, + output: None, + error: Some("cancelled before spawn".to_string()), + }; + } + let prompt = phase_prompt(&run_input, &phase, agent_index, &upstream); + let resp = match session + .spawn_agent(SpawnAgentRequest { + agent_id: agent_id.clone(), + prompt, + model, + ..Default::default() + }) + .await + { + Ok(resp) => resp, + Err(err) => { + return PhaseWorkerOutcome { + orchestration_id: None, + output: None, + error: Some(format!("spawn failed for agent '{agent_id}': {err}")), + }; + } + }; + let oid = resp.orchestration_id.clone(); + let wait = match session + .wait_agents(WaitAgentOptions { + orchestration_ids: vec![oid.clone()], + timeout_ms: None, + }) + .await + { + Ok(w) => w, + Err(err) => { + return PhaseWorkerOutcome { + orchestration_id: Some(oid), + output: None, + error: Some(format!("wait_agents failed: {err}")), + }; + } + }; + match wait.agents.into_iter().next() { + Some(s) => match s.status { + AgentStatus::Completed => PhaseWorkerOutcome { + orchestration_id: Some(oid), + output: Some(json!({ + "orchestrationId": s.orchestration_id, + "agentId": s.agent_id, + "output": s.result_summary.clone().unwrap_or_default(), + })), + error: None, + }, + AgentStatus::Failed | AgentStatus::Cancelled | AgentStatus::Closed => { + PhaseWorkerOutcome { + orchestration_id: Some(oid), + output: None, + error: Some(format!( + "child '{}' (agent '{}') ended {}: {}", + s.orchestration_id, + s.agent_id, + serde_json::to_value(s.status) + .ok() + .and_then(|v| v.as_str().map(str::to_string)) + .unwrap_or_else(|| "non-completed".to_string()), + s.error.clone().unwrap_or_default() + )), + } + } + AgentStatus::Pending | AgentStatus::Running | AgentStatus::Waiting => { + PhaseWorkerOutcome { + orchestration_id: Some(oid), + output: None, + error: Some(format!( + "child '{}' returned non-terminal status", + s.orchestration_id + )), + } + } + }, + None => PhaseWorkerOutcome { + orchestration_id: Some(oid), + output: None, + error: Some("child returned no snapshot".to_string()), + }, + } + } + }, + ) + .await + .map_err(|e| anyhow!(e))?; + + // Aggregate worker outcomes in phase order: record every spawned + // child id, collect completed outputs, and surface the first failure. + for outcome in outcomes { + if let Some(oid) = outcome.orchestration_id { + spawned_this_phase += 1; + child_run_ids.push(oid); + } + match outcome.output { + Some(out) => phase_outputs.push(out), + None => { + if phase_failed.is_none() { + phase_failed = outcome.error; + } + } + } + } + + // Cancellation landed mid-phase: abort stragglers and interrupt. + if cancel.load(Ordering::SeqCst) { session.abort_all().await; persist( config, @@ -384,223 +654,67 @@ async fn drive_phases( None, false, )?; - return Ok(()); + return Ok(PhaseExecOutcome::Terminated); } - // Find the next runnable phase: pending, with all deps completed. - let Some(phase) = next_runnable_phase(definition, &phase_states) else { - // No runnable phase left. Either everything is done, or we're - // blocked (which a validated DAG shouldn't be). - if all_phases_completed(definition, &phase_states) { - let summary = synthesize_summary(definition, &phase_states); - log::debug!( - target: LOG_TARGET, - "[workflow_run_engine] loop.completed run={run_id} summary_chars={}", - summary.as_deref().map(str::len).unwrap_or(0) - ); - persist( - config, - &run, - phase_states, - child_run_ids, - WorkflowRunStatus::Completed, - summary, - true, - )?; - } else { - log::warn!( - target: LOG_TARGET, - "[workflow_run_engine] loop.stuck run={run_id} no_runnable_phase" - ); - persist( - config, - &run, - phase_states, - child_run_ids, - WorkflowRunStatus::Failed, - Some("no runnable phase (dependency deadlock)".to_string()), - true, - )?; - } - return Ok(()); - }; + if capped && phase_failed.is_none() { + phase_failed = Some(format!( + "max_children cap ({}) reached before phase '{}' completed", + definition.max_children, phase.name + )); + } + } - log::debug!( + if let Some(reason) = phase_failed { + log::warn!( target: LOG_TARGET, - "[workflow_run_engine] phase.start run={run_id} phase={} agents={} spawned_so_far={}", - phase.name, - phase.agent_ids.len(), - total_spawned - ); - set_phase_status(&mut phase_states, &phase.name, PHASE_RUNNING, None); - persist( - config, - &run, - phase_states.clone(), - child_run_ids.clone(), - WorkflowRunStatus::Running, - None, - false, - )?; - - // Thread prior phases' outputs into this phase's prompt context. - let upstream_context = upstream_outputs(definition, phase, &phase_states); - - // Spawn the phase's agents in concurrency-bounded batches, never - // exceeding the run-wide `max_children` cap. - let mut phase_outputs: Vec = Vec::new(); - let mut phase_failed: Option = None; - let mut index_in_phase = 0usize; - - let concurrency = definition.default_concurrency.max(1); - let mut pending = phase.agent_ids.to_vec(); - - 'phase: while !pending.is_empty() { - // Cancellation can also land mid-phase between batches. - if cancel.load(Ordering::SeqCst) { - session.abort_all().await; - persist( - config, - &run, - phase_states, - child_run_ids, - WorkflowRunStatus::Interrupted, - None, - false, - )?; - return Ok(()); - } - - let budget_left = definition.max_children.saturating_sub(total_spawned); - if budget_left == 0 { - phase_failed = Some(format!( - "max_children cap ({}) reached before phase '{}' completed", - definition.max_children, phase.name - )); - break 'phase; - } - let batch_size = (concurrency as usize) - .min(budget_left as usize) - .min(pending.len()) - .max(1); - let batch: Vec = pending.drain(..batch_size).collect(); - - let mut batch_ids: Vec<(String, String)> = Vec::new(); // (orchestration_id, agent_id) - for agent_id in &batch { - let prompt = phase_prompt(&run.input, phase, index_in_phase, &upstream_context); - index_in_phase += 1; - match session - .spawn_agent(SpawnAgentRequest { - agent_id: agent_id.clone(), - prompt, - model: model_override.clone(), - ..Default::default() - }) - .await - { - Ok(resp) => { - total_spawned += 1; - child_run_ids.push(resp.orchestration_id.clone()); - batch_ids.push((resp.orchestration_id, agent_id.clone())); - } - Err(err) => { - phase_failed = Some(format!("spawn failed for agent '{agent_id}': {err}")); - break 'phase; - } - } - } - - // Wait for this batch to reach terminal status. - let wait = session - .wait_agents(WaitAgentOptions { - orchestration_ids: batch_ids.iter().map(|(id, _)| id.clone()).collect(), - timeout_ms: None, - }) - .await - .map_err(|e| anyhow!("wait_agents failed: {e}"))?; - - for snapshot in &wait.agents { - match snapshot.status { - AgentStatus::Completed => { - phase_outputs.push(json!({ - "orchestrationId": snapshot.orchestration_id, - "agentId": snapshot.agent_id, - "output": snapshot.result_summary.clone().unwrap_or_default(), - })); - } - AgentStatus::Failed | AgentStatus::Cancelled | AgentStatus::Closed => { - phase_failed = Some(format!( - "child '{}' (agent '{}') ended {}: {}", - snapshot.orchestration_id, - snapshot.agent_id, - serde_json::to_value(snapshot.status) - .ok() - .and_then(|v| v.as_str().map(str::to_string)) - .unwrap_or_else(|| "non-completed".to_string()), - snapshot.error.clone().unwrap_or_default() - )); - break 'phase; - } - AgentStatus::Pending | AgentStatus::Running | AgentStatus::Waiting => { - // wait_agents with no timeout only returns on terminal, - // so this is defensive. - phase_failed = Some(format!( - "child '{}' returned non-terminal status", - snapshot.orchestration_id - )); - break 'phase; - } - } - } - } - - if let Some(reason) = phase_failed { - log::warn!( - target: LOG_TARGET, - "[workflow_run_engine] phase.failed run={run_id} phase={} reason={reason}", - phase.name - ); - set_phase_status( - &mut phase_states, - &phase.name, - PHASE_FAILED, - Some(json!([])), - ); - set_phase_reason(&mut phase_states, &phase.name, &reason); - persist( - config, - &run, - phase_states, - child_run_ids, - WorkflowRunStatus::Failed, - Some(reason), - true, - )?; - return Ok(()); - } - - log::debug!( - target: LOG_TARGET, - "[workflow_run_engine] phase.done run={run_id} phase={} outputs={}", - phase.name, - phase_outputs.len() + "[workflow_run_engine] phase.failed run={run_id} phase={} reason={reason}", + phase.name ); set_phase_status( &mut phase_states, &phase.name, - PHASE_COMPLETED, - Some(Value::Array(phase_outputs)), + PHASE_FAILED, + Some(json!([])), ); + set_phase_reason(&mut phase_states, &phase.name, &reason); persist( config, &run, phase_states, child_run_ids, - WorkflowRunStatus::Running, - None, - false, + WorkflowRunStatus::Failed, + Some(reason), + true, )?; + return Ok(PhaseExecOutcome::Terminated); } + + log::debug!( + target: LOG_TARGET, + "[workflow_run_engine] phase.done run={run_id} phase={} outputs={}", + phase.name, + phase_outputs.len() + ); + set_phase_status( + &mut phase_states, + &phase.name, + PHASE_COMPLETED, + Some(Value::Array(phase_outputs)), + ); + persist( + config, + &run, + phase_states, + child_run_ids, + WorkflowRunStatus::Running, + None, + false, + )?; + + Ok(PhaseExecOutcome::Continue { + spawned: spawned_this_phase, + }) } // ─────────────────────────────────────────────────────────────────────────── diff --git a/src/openhuman/agent_orchestration/workflow_runs/engine_tests.rs b/src/openhuman/agent_orchestration/workflow_runs/engine_tests.rs index 5670454eb..6faa742a5 100644 --- a/src/openhuman/agent_orchestration/workflow_runs/engine_tests.rs +++ b/src/openhuman/agent_orchestration/workflow_runs/engine_tests.rs @@ -1,6 +1,6 @@ //! Engine unit tests (#3375 PR2). //! -//! These exercise the phase scheduler ([`super::drive_phases`]) directly with a +//! These exercise the phase scheduler ([`super::super::graph::drive_phases`]) directly with a //! mock `Provider` so child agents resolve deterministically and never touch the //! network. The full [`super::start_workflow_run`] entry point (which builds a //! real `Agent` from config) is covered by the JSON-RPC e2e test over the live @@ -21,6 +21,9 @@ use serde_json::{json, Value}; use tokio::time::{sleep, Duration}; use super::*; +// The phase scheduler moved to the tinyagents-backed `graph` submodule in #4249; +// its signature is unchanged (`drive_phases(config, run_id, definition, cancel)`). +use super::super::graph::drive_phases; use crate::openhuman::agent::harness::definition::AgentDefinitionRegistry; use crate::openhuman::agent::harness::fork_context::{with_parent_context, ParentExecutionContext}; use crate::openhuman::config::{AgentConfig, Config}; diff --git a/src/openhuman/agent_orchestration/workflow_runs/graph.rs b/src/openhuman/agent_orchestration/workflow_runs/graph.rs new file mode 100644 index 000000000..c844a0ec7 --- /dev/null +++ b/src/openhuman/agent_orchestration/workflow_runs/graph.rs @@ -0,0 +1,222 @@ +//! The **workflow scheduler graph** (issue #4249, Phase 4). +//! +//! This is the `workflow_runs` folder's `graph.rs` per the per-folder graph +//! convention: the durable workflow run's phase-DAG scheduler expressed as a +//! `tinyagents` conditional-routing graph. A `dispatch` node selects the next +//! runnable phase and a `run_phase` node executes it, looping +//! `dispatch ⇄ run_phase` until no phase remains, then routing to `done`. +//! +//! The phase-execution logic (`select_next_phase`, `execute_phase`) and the run +//! lifecycle (`start_workflow_run` / `stop_workflow_run` / `resume_workflow_run`) +//! stay in [`super::engine`]; this module owns only the graph state machine that +//! drives them. + +use std::sync::atomic::AtomicBool; +use std::sync::Arc; + +use anyhow::{anyhow, Result}; +use serde_json::Value; + +use crate::openhuman::config::Config; +use crate::openhuman::session_db::run_ledger::get_workflow_run; + +use super::engine::{execute_phase, select_next_phase, PhaseExecOutcome, PhaseSelection}; +use super::types::{WorkflowDefinition, WorkflowPhase}; + +/// Lift an engine-internal `anyhow` error into the scheduler graph's error type +/// so a ledger-write/spawn failure fails the run (and propagates back out via +/// [`drive_phases`]). A *phase* that merely failed is not an error here — it is a +/// normal `Terminated` outcome that persists `Failed` and routes to `done`. +fn graph_err(e: anyhow::Error) -> tinyagents::TinyAgentsError { + tinyagents::TinyAgentsError::Graph(e.to_string()) +} + +/// Typed state threaded through the scheduler graph: the phase `dispatch` +/// selected for `run_phase`, and the running tally of spawned children (the +/// `max_children` budget counter, carried across the dispatch⇄run_phase cycle). +#[derive(Clone, Default)] +struct SchedulerState { + phase: Option, + total_spawned: u32, +} + +/// Reducer updates emitted by the scheduler graph nodes. +enum SchedulerUpdate { + /// `dispatch` chose the next phase to run. + SelectPhase(WorkflowPhase), + /// `run_phase` spawned this many children; fold into `total_spawned`. + AddSpawned(u32), + /// Terminal node fired; no state change. + Noop, +} + +/// Topologically walk the phase DAG on a `tinyagents` conditional-routing graph +/// (issue #4249, Phase 4): a `dispatch` node selects the next runnable phase and +/// a `run_phase` node executes it, looping `dispatch ⇄ run_phase` until no phase +/// remains, then routing to `done`: +/// +/// ```text +/// dispatch ──phase──► run_phase ──► dispatch ──none/terminal──► done +/// ``` +/// +/// This replaces the hand-rolled `loop {}` scheduler with a graph state machine +/// while keeping the durable `workflow_runs` row as the source of truth (every +/// node reloads + persists it, so resume picks up persisted phase progress and +/// the read controllers see the same projection). Returns `Ok(())` once a +/// terminal status (Completed / Failed / Interrupted) is written; returns `Err` +/// only for engine-internal failures (ledger writes, graph mechanics). +pub(super) async fn drive_phases( + config: &Config, + run_id: &str, + definition: &WorkflowDefinition, + cancel: &Arc, +) -> Result<()> { + use crate::openhuman::agent_orchestration::AgentOrchestrationSession; + use crate::openhuman::tinyagents::observability::GraphTracingSink; + use tinyagents::graph::recursion::RecursionPolicy; + use tinyagents::graph::{ClosureStateReducer, Command, GraphBuilder, NodeContext, NodeResult}; + + let session = AgentOrchestrationSession::new(format!("workflow-engine-{run_id}")); + + // Optional per-run model override. When present in the run input + // (`{"modelOverride": "..."}`), every child is forced onto the parent + // (root) provider with this model instead of its declarative workload + // provider. Production runs omit it (agents use their configured provider); + // deterministic mock-backend tests set it so children resolve to the + // injected mock provider. + let model_override = get_workflow_run(config, run_id)? + .and_then(|r| { + r.input + .get("modelOverride") + .and_then(Value::as_str) + .map(str::to_string) + }) + .filter(|m| !m.trim().is_empty()); + + // `'static` captures for the graph node handlers (they are `Fn`, re-entered + // once per phase, so each invocation re-clones from these). + let config = Arc::new(config.clone()); + let definition = Arc::new(definition.clone()); + let run_id_owned = run_id.to_string(); + let cancel = cancel.clone(); + + let mut builder = GraphBuilder::::new().set_reducer( + ClosureStateReducer::new(|mut s: SchedulerState, u: SchedulerUpdate| { + match u { + SchedulerUpdate::SelectPhase(p) => s.phase = Some(p), + SchedulerUpdate::AddSpawned(n) => s.total_spawned += n, + SchedulerUpdate::Noop => {} + } + Ok(s) + }), + ); + + // `dispatch`: pick the next runnable phase, or terminate (already persisted). + { + let config = config.clone(); + let definition = definition.clone(); + let run_id = run_id_owned.clone(); + let cancel = cancel.clone(); + let session = session.clone(); + builder = builder.add_node("dispatch", move |_s: SchedulerState, _c: NodeContext| { + let config = config.clone(); + let definition = definition.clone(); + let run_id = run_id.clone(); + let cancel = cancel.clone(); + let session = session.clone(); + async move { + match select_next_phase(&config, &run_id, &definition, &cancel, &session) + .await + .map_err(graph_err)? + { + PhaseSelection::Run(phase) => Ok(NodeResult::Command( + Command::default() + .with_update(SchedulerUpdate::SelectPhase(phase)) + .with_goto(["run_phase"]), + )), + PhaseSelection::Terminated => { + Ok(NodeResult::Command(Command::default().with_goto(["done"]))) + } + } + } + }); + } + + // `run_phase`: execute the selected phase, then loop back to `dispatch` or + // terminate (on phase failure / mid-phase cancellation). + { + let config = config.clone(); + let definition = definition.clone(); + let run_id = run_id_owned.clone(); + let cancel = cancel.clone(); + let session = session.clone(); + let model_override = model_override.clone(); + builder = builder.add_node("run_phase", move |s: SchedulerState, _c: NodeContext| { + let config = config.clone(); + let definition = definition.clone(); + let run_id = run_id.clone(); + let cancel = cancel.clone(); + let session = session.clone(); + let model_override = model_override.clone(); + async move { + let phase = s.phase.clone().ok_or_else(|| { + tinyagents::TinyAgentsError::Graph( + "workflow run_phase reached with no selected phase".to_string(), + ) + })?; + match execute_phase( + &config, + &run_id, + &definition, + &session, + &cancel, + model_override, + &phase, + s.total_spawned, + ) + .await + .map_err(graph_err)? + { + PhaseExecOutcome::Continue { spawned } => Ok(NodeResult::Command( + Command::default() + .with_update(SchedulerUpdate::AddSpawned(spawned)) + .with_goto(["dispatch"]), + )), + PhaseExecOutcome::Terminated => { + Ok(NodeResult::Command(Command::default().with_goto(["done"]))) + } + } + } + }); + } + + let phase_count = definition.phases.len(); + let graph = builder + .add_node("done", |_s: SchedulerState, _c: NodeContext| async move { + Ok(NodeResult::Update(SchedulerUpdate::Noop)) + }) + .set_entry("dispatch") + .mark_command_routing("dispatch") + .mark_command_routing("run_phase") + .set_finish("done") + .compile() + .map_err(|e| anyhow!("workflow scheduler graph compile failed: {e}"))? + .with_event_sink(Arc::new(GraphTracingSink::new(format!( + "workflow:{run_id_owned}" + )))) + // Bound the dispatch⇄run_phase cycle as a backstop to the DAG's own + // termination: `dispatch` is visited once per phase plus a final + // no-phase visit, `run_phase` once per phase. A validated DAG always + // drains, so this only guards a malformed definition. + .with_recursion_policy(RecursionPolicy { + max_visits_per_node: Some(phase_count + 2), + max_total_steps: (phase_count + 1) * 3 + 16, + ..RecursionPolicy::default() + }); + + graph + .run(SchedulerState::default()) + .await + .map_err(|e| anyhow!("workflow scheduler graph run failed: {e}"))?; + Ok(()) +} diff --git a/src/openhuman/agent_orchestration/workflow_runs/mod.rs b/src/openhuman/agent_orchestration/workflow_runs/mod.rs index 470315484..105289f0a 100644 --- a/src/openhuman/agent_orchestration/workflow_runs/mod.rs +++ b/src/openhuman/agent_orchestration/workflow_runs/mod.rs @@ -21,6 +21,7 @@ //! handles SKILL.md / WORKFLOW.md bundle discovery. mod engine; +mod graph; mod ops; mod schemas; pub mod types; diff --git a/src/openhuman/agent_registry/agents/account_admin_agent/graph.rs b/src/openhuman/agent_registry/agents/account_admin_agent/graph.rs new file mode 100644 index 000000000..5633c4b13 --- /dev/null +++ b/src/openhuman/agent_registry/agents/account_admin_agent/graph.rs @@ -0,0 +1,13 @@ +//! Turn graph for the `account_admin_agent` built-in agent. +//! +//! Uses the shared default sub-agent turn graph (`run_subagent_via_graph`) — see +//! [`crate::openhuman::agent::harness::agent_graph`]. Replace the body with +//! `AgentGraph::custom(run)` to give this agent a bespoke tinyagents graph. + +use crate::openhuman::agent::harness::agent_graph::AgentGraph; + +/// Select this agent's turn graph. This is a default agent — it uses the shared +/// default graph rather than defining its own. +pub fn graph() -> AgentGraph { + AgentGraph::Default +} diff --git a/src/openhuman/agent_registry/agents/account_admin_agent/mod.rs b/src/openhuman/agent_registry/agents/account_admin_agent/mod.rs index 8bf84783c..cedb79d0e 100644 --- a/src/openhuman/agent_registry/agents/account_admin_agent/mod.rs +++ b/src/openhuman/agent_registry/agents/account_admin_agent/mod.rs @@ -1 +1,2 @@ +pub mod graph; pub mod prompt; diff --git a/src/openhuman/agent_registry/agents/archivist/graph.rs b/src/openhuman/agent_registry/agents/archivist/graph.rs new file mode 100644 index 000000000..e32c17bd9 --- /dev/null +++ b/src/openhuman/agent_registry/agents/archivist/graph.rs @@ -0,0 +1,13 @@ +//! Turn graph for the `archivist` built-in agent. +//! +//! Uses the shared default sub-agent turn graph (`run_subagent_via_graph`) — see +//! [`crate::openhuman::agent::harness::agent_graph`]. Replace the body with +//! `AgentGraph::custom(run)` to give this agent a bespoke tinyagents graph. + +use crate::openhuman::agent::harness::agent_graph::AgentGraph; + +/// Select this agent's turn graph. This is a default agent — it uses the shared +/// default graph rather than defining its own. +pub fn graph() -> AgentGraph { + AgentGraph::Default +} diff --git a/src/openhuman/agent_registry/agents/archivist/mod.rs b/src/openhuman/agent_registry/agents/archivist/mod.rs index 8bf84783c..cedb79d0e 100644 --- a/src/openhuman/agent_registry/agents/archivist/mod.rs +++ b/src/openhuman/agent_registry/agents/archivist/mod.rs @@ -1 +1,2 @@ +pub mod graph; pub mod prompt; diff --git a/src/openhuman/agent_registry/agents/code_executor/graph.rs b/src/openhuman/agent_registry/agents/code_executor/graph.rs new file mode 100644 index 000000000..5c0b1d8ba --- /dev/null +++ b/src/openhuman/agent_registry/agents/code_executor/graph.rs @@ -0,0 +1,13 @@ +//! Turn graph for the `code_executor` built-in agent. +//! +//! Uses the shared default sub-agent turn graph (`run_subagent_via_graph`) — see +//! [`crate::openhuman::agent::harness::agent_graph`]. Replace the body with +//! `AgentGraph::custom(run)` to give this agent a bespoke tinyagents graph. + +use crate::openhuman::agent::harness::agent_graph::AgentGraph; + +/// Select this agent's turn graph. This is a default agent — it uses the shared +/// default graph rather than defining its own. +pub fn graph() -> AgentGraph { + AgentGraph::Default +} diff --git a/src/openhuman/agent_registry/agents/code_executor/mod.rs b/src/openhuman/agent_registry/agents/code_executor/mod.rs index 8bf84783c..cedb79d0e 100644 --- a/src/openhuman/agent_registry/agents/code_executor/mod.rs +++ b/src/openhuman/agent_registry/agents/code_executor/mod.rs @@ -1 +1,2 @@ +pub mod graph; pub mod prompt; diff --git a/src/openhuman/agent_registry/agents/context_scout/graph.rs b/src/openhuman/agent_registry/agents/context_scout/graph.rs new file mode 100644 index 000000000..b0e086433 --- /dev/null +++ b/src/openhuman/agent_registry/agents/context_scout/graph.rs @@ -0,0 +1,13 @@ +//! Turn graph for the `context_scout` built-in agent. +//! +//! Uses the shared default sub-agent turn graph (`run_subagent_via_graph`) — see +//! [`crate::openhuman::agent::harness::agent_graph`]. Replace the body with +//! `AgentGraph::custom(run)` to give this agent a bespoke tinyagents graph. + +use crate::openhuman::agent::harness::agent_graph::AgentGraph; + +/// Select this agent's turn graph. This is a default agent — it uses the shared +/// default graph rather than defining its own. +pub fn graph() -> AgentGraph { + AgentGraph::Default +} diff --git a/src/openhuman/agent_registry/agents/context_scout/mod.rs b/src/openhuman/agent_registry/agents/context_scout/mod.rs index 8bf84783c..cedb79d0e 100644 --- a/src/openhuman/agent_registry/agents/context_scout/mod.rs +++ b/src/openhuman/agent_registry/agents/context_scout/mod.rs @@ -1 +1,2 @@ +pub mod graph; pub mod prompt; diff --git a/src/openhuman/agent_registry/agents/critic/graph.rs b/src/openhuman/agent_registry/agents/critic/graph.rs new file mode 100644 index 000000000..92700162a --- /dev/null +++ b/src/openhuman/agent_registry/agents/critic/graph.rs @@ -0,0 +1,13 @@ +//! Turn graph for the `critic` built-in agent. +//! +//! Uses the shared default sub-agent turn graph (`run_subagent_via_graph`) — see +//! [`crate::openhuman::agent::harness::agent_graph`]. Replace the body with +//! `AgentGraph::custom(run)` to give this agent a bespoke tinyagents graph. + +use crate::openhuman::agent::harness::agent_graph::AgentGraph; + +/// Select this agent's turn graph. This is a default agent — it uses the shared +/// default graph rather than defining its own. +pub fn graph() -> AgentGraph { + AgentGraph::Default +} diff --git a/src/openhuman/agent_registry/agents/critic/mod.rs b/src/openhuman/agent_registry/agents/critic/mod.rs index 8bf84783c..cedb79d0e 100644 --- a/src/openhuman/agent_registry/agents/critic/mod.rs +++ b/src/openhuman/agent_registry/agents/critic/mod.rs @@ -1 +1,2 @@ +pub mod graph; pub mod prompt; diff --git a/src/openhuman/agent_registry/agents/crypto_agent/graph.rs b/src/openhuman/agent_registry/agents/crypto_agent/graph.rs new file mode 100644 index 000000000..e7f1a177a --- /dev/null +++ b/src/openhuman/agent_registry/agents/crypto_agent/graph.rs @@ -0,0 +1,13 @@ +//! Turn graph for the `crypto_agent` built-in agent. +//! +//! Uses the shared default sub-agent turn graph (`run_subagent_via_graph`) — see +//! [`crate::openhuman::agent::harness::agent_graph`]. Replace the body with +//! `AgentGraph::custom(run)` to give this agent a bespoke tinyagents graph. + +use crate::openhuman::agent::harness::agent_graph::AgentGraph; + +/// Select this agent's turn graph. This is a default agent — it uses the shared +/// default graph rather than defining its own. +pub fn graph() -> AgentGraph { + AgentGraph::Default +} diff --git a/src/openhuman/agent_registry/agents/crypto_agent/mod.rs b/src/openhuman/agent_registry/agents/crypto_agent/mod.rs index 8bf84783c..cedb79d0e 100644 --- a/src/openhuman/agent_registry/agents/crypto_agent/mod.rs +++ b/src/openhuman/agent_registry/agents/crypto_agent/mod.rs @@ -1 +1,2 @@ +pub mod graph; pub mod prompt; diff --git a/src/openhuman/agent_registry/agents/desktop_control_agent/graph.rs b/src/openhuman/agent_registry/agents/desktop_control_agent/graph.rs new file mode 100644 index 000000000..a36b3edc9 --- /dev/null +++ b/src/openhuman/agent_registry/agents/desktop_control_agent/graph.rs @@ -0,0 +1,13 @@ +//! Turn graph for the `desktop_control_agent` built-in agent. +//! +//! Uses the shared default sub-agent turn graph (`run_subagent_via_graph`) — see +//! [`crate::openhuman::agent::harness::agent_graph`]. Replace the body with +//! `AgentGraph::custom(run)` to give this agent a bespoke tinyagents graph. + +use crate::openhuman::agent::harness::agent_graph::AgentGraph; + +/// Select this agent's turn graph. This is a default agent — it uses the shared +/// default graph rather than defining its own. +pub fn graph() -> AgentGraph { + AgentGraph::Default +} diff --git a/src/openhuman/agent_registry/agents/desktop_control_agent/mod.rs b/src/openhuman/agent_registry/agents/desktop_control_agent/mod.rs index 8bf84783c..cedb79d0e 100644 --- a/src/openhuman/agent_registry/agents/desktop_control_agent/mod.rs +++ b/src/openhuman/agent_registry/agents/desktop_control_agent/mod.rs @@ -1 +1,2 @@ +pub mod graph; pub mod prompt; diff --git a/src/openhuman/agent_registry/agents/goals_agent/graph.rs b/src/openhuman/agent_registry/agents/goals_agent/graph.rs new file mode 100644 index 000000000..82bd4d108 --- /dev/null +++ b/src/openhuman/agent_registry/agents/goals_agent/graph.rs @@ -0,0 +1,13 @@ +//! Turn graph for the `goals_agent` built-in agent. +//! +//! Uses the shared default sub-agent turn graph (`run_subagent_via_graph`) — see +//! [`crate::openhuman::agent::harness::agent_graph`]. Replace the body with +//! `AgentGraph::custom(run)` to give this agent a bespoke tinyagents graph. + +use crate::openhuman::agent::harness::agent_graph::AgentGraph; + +/// Select this agent's turn graph. This is a default agent — it uses the shared +/// default graph rather than defining its own. +pub fn graph() -> AgentGraph { + AgentGraph::Default +} diff --git a/src/openhuman/agent_registry/agents/goals_agent/mod.rs b/src/openhuman/agent_registry/agents/goals_agent/mod.rs index 8bf84783c..cedb79d0e 100644 --- a/src/openhuman/agent_registry/agents/goals_agent/mod.rs +++ b/src/openhuman/agent_registry/agents/goals_agent/mod.rs @@ -1 +1,2 @@ +pub mod graph; pub mod prompt; diff --git a/src/openhuman/agent_registry/agents/help/graph.rs b/src/openhuman/agent_registry/agents/help/graph.rs new file mode 100644 index 000000000..23c8a028d --- /dev/null +++ b/src/openhuman/agent_registry/agents/help/graph.rs @@ -0,0 +1,13 @@ +//! Turn graph for the `help` built-in agent. +//! +//! Uses the shared default sub-agent turn graph (`run_subagent_via_graph`) — see +//! [`crate::openhuman::agent::harness::agent_graph`]. Replace the body with +//! `AgentGraph::custom(run)` to give this agent a bespoke tinyagents graph. + +use crate::openhuman::agent::harness::agent_graph::AgentGraph; + +/// Select this agent's turn graph. This is a default agent — it uses the shared +/// default graph rather than defining its own. +pub fn graph() -> AgentGraph { + AgentGraph::Default +} diff --git a/src/openhuman/agent_registry/agents/help/mod.rs b/src/openhuman/agent_registry/agents/help/mod.rs index 8bf84783c..cedb79d0e 100644 --- a/src/openhuman/agent_registry/agents/help/mod.rs +++ b/src/openhuman/agent_registry/agents/help/mod.rs @@ -1 +1,2 @@ +pub mod graph; pub mod prompt; diff --git a/src/openhuman/agent_registry/agents/image_agent/graph.rs b/src/openhuman/agent_registry/agents/image_agent/graph.rs new file mode 100644 index 000000000..dcf12d4ee --- /dev/null +++ b/src/openhuman/agent_registry/agents/image_agent/graph.rs @@ -0,0 +1,13 @@ +//! Turn graph for the `image_agent` built-in agent. +//! +//! Uses the shared default sub-agent turn graph (`run_subagent_via_graph`) — see +//! [`crate::openhuman::agent::harness::agent_graph`]. Replace the body with +//! `AgentGraph::custom(run)` to give this agent a bespoke tinyagents graph. + +use crate::openhuman::agent::harness::agent_graph::AgentGraph; + +/// Select this agent's turn graph. This is a default agent — it uses the shared +/// default graph rather than defining its own. +pub fn graph() -> AgentGraph { + AgentGraph::Default +} diff --git a/src/openhuman/agent_registry/agents/image_agent/mod.rs b/src/openhuman/agent_registry/agents/image_agent/mod.rs index 8bf84783c..cedb79d0e 100644 --- a/src/openhuman/agent_registry/agents/image_agent/mod.rs +++ b/src/openhuman/agent_registry/agents/image_agent/mod.rs @@ -1 +1,2 @@ +pub mod graph; pub mod prompt; diff --git a/src/openhuman/agent_registry/agents/integrations_agent/graph.rs b/src/openhuman/agent_registry/agents/integrations_agent/graph.rs new file mode 100644 index 000000000..0705f3a48 --- /dev/null +++ b/src/openhuman/agent_registry/agents/integrations_agent/graph.rs @@ -0,0 +1,13 @@ +//! Turn graph for the `integrations_agent` built-in agent. +//! +//! Uses the shared default sub-agent turn graph (`run_subagent_via_graph`) — see +//! [`crate::openhuman::agent::harness::agent_graph`]. Replace the body with +//! `AgentGraph::custom(run)` to give this agent a bespoke tinyagents graph. + +use crate::openhuman::agent::harness::agent_graph::AgentGraph; + +/// Select this agent's turn graph. This is a default agent — it uses the shared +/// default graph rather than defining its own. +pub fn graph() -> AgentGraph { + AgentGraph::Default +} diff --git a/src/openhuman/agent_registry/agents/integrations_agent/mod.rs b/src/openhuman/agent_registry/agents/integrations_agent/mod.rs index 8bf84783c..cedb79d0e 100644 --- a/src/openhuman/agent_registry/agents/integrations_agent/mod.rs +++ b/src/openhuman/agent_registry/agents/integrations_agent/mod.rs @@ -1 +1,2 @@ +pub mod graph; pub mod prompt; diff --git a/src/openhuman/agent_registry/agents/loader.rs b/src/openhuman/agent_registry/agents/loader.rs index 640f50ae6..753557851 100644 --- a/src/openhuman/agent_registry/agents/loader.rs +++ b/src/openhuman/agent_registry/agents/loader.rs @@ -34,6 +34,7 @@ //! into the global registry, where they replace built-ins on `id` //! collision. +use crate::openhuman::agent::harness::agent_graph::AgentGraph; use crate::openhuman::agent::harness::definition::{ validate_tier_transition, AgentDefinition, AgentTier, DefinitionSource, PromptBuilder, PromptSource, SubagentEntry, @@ -53,6 +54,11 @@ pub struct BuiltinAgent { /// with a populated [`crate::openhuman::agent::harness::definition::PromptContext`] /// so the returned body can branch on runtime state. pub prompt_fn: PromptBuilder, + /// Turn-graph selector. Invoked at load time to stamp the agent's + /// [`AgentGraph`] onto its [`AgentDefinition`] (mirrors `prompt_fn`). The + /// agent's `graph.rs::graph()` returns [`AgentGraph::Default`] (shared graph) + /// or [`AgentGraph::Custom`] (bespoke graph). + pub graph_fn: fn() -> AgentGraph, } /// Every built-in agent, in stable display order. @@ -63,186 +69,223 @@ pub const BUILTINS: &[BuiltinAgent] = &[ id: "orchestrator", toml: include_str!("orchestrator/agent.toml"), prompt_fn: super::orchestrator::prompt::build, + graph_fn: super::orchestrator::graph::graph, }, BuiltinAgent { id: "planner", toml: include_str!("planner/agent.toml"), prompt_fn: super::planner::prompt::build, + graph_fn: super::planner::graph::graph, }, BuiltinAgent { id: "code_executor", toml: include_str!("code_executor/agent.toml"), prompt_fn: super::code_executor::prompt::build, + graph_fn: super::code_executor::graph::graph, }, BuiltinAgent { id: "integrations_agent", toml: include_str!("integrations_agent/agent.toml"), prompt_fn: super::integrations_agent::prompt::build, + graph_fn: super::integrations_agent::graph::graph, }, BuiltinAgent { id: "crypto_agent", toml: include_str!("crypto_agent/agent.toml"), prompt_fn: super::crypto_agent::prompt::build, + graph_fn: super::crypto_agent::graph::graph, }, BuiltinAgent { id: "markets_agent", toml: include_str!("markets_agent/agent.toml"), prompt_fn: super::markets_agent::prompt::build, + graph_fn: super::markets_agent::graph::graph, }, BuiltinAgent { id: "tinyplace_agent", toml: include_str!("../../tinyplace/agent/agent.toml"), prompt_fn: crate::openhuman::tinyplace::agent::prompt::build, + graph_fn: crate::openhuman::tinyplace::agent::graph::graph, }, BuiltinAgent { id: "tools_agent", toml: include_str!("tools_agent/agent.toml"), prompt_fn: super::tools_agent::prompt::build, + graph_fn: super::tools_agent::graph::graph, }, BuiltinAgent { id: "task_manager_agent", toml: include_str!("task_manager_agent/agent.toml"), prompt_fn: super::task_manager_agent::prompt::build, + graph_fn: super::task_manager_agent::graph::graph, }, BuiltinAgent { id: "settings_agent", toml: include_str!("settings_agent/agent.toml"), prompt_fn: super::settings_agent::prompt::build, + graph_fn: super::settings_agent::graph::graph, }, BuiltinAgent { id: "profile_memory_agent", toml: include_str!("profile_memory_agent/agent.toml"), prompt_fn: super::profile_memory_agent::prompt::build, + graph_fn: super::profile_memory_agent::graph::graph, }, BuiltinAgent { id: "account_admin_agent", toml: include_str!("account_admin_agent/agent.toml"), prompt_fn: super::account_admin_agent::prompt::build, + graph_fn: super::account_admin_agent::graph::graph, }, BuiltinAgent { id: "screen_awareness_agent", toml: include_str!("screen_awareness_agent/agent.toml"), prompt_fn: super::screen_awareness_agent::prompt::build, + graph_fn: super::screen_awareness_agent::graph::graph, }, BuiltinAgent { id: "scheduler_agent", toml: include_str!("scheduler_agent/agent.toml"), prompt_fn: super::scheduler_agent::prompt::build, + graph_fn: super::scheduler_agent::graph::graph, }, BuiltinAgent { id: "presentation_agent", toml: include_str!("presentation_agent/agent.toml"), prompt_fn: super::presentation_agent::prompt::build, + graph_fn: super::presentation_agent::graph::graph, }, BuiltinAgent { id: "desktop_control_agent", toml: include_str!("desktop_control_agent/agent.toml"), prompt_fn: super::desktop_control_agent::prompt::build, + graph_fn: super::desktop_control_agent::graph::graph, }, BuiltinAgent { id: "tool_maker", toml: include_str!("tool_maker/agent.toml"), prompt_fn: super::tool_maker::prompt::build, + graph_fn: super::tool_maker::graph::graph, }, BuiltinAgent { id: "skill_creator", toml: include_str!("skill_creator/agent.toml"), prompt_fn: super::skill_creator::prompt::build, + graph_fn: super::skill_creator::graph::graph, }, BuiltinAgent { id: "researcher", toml: include_str!("researcher/agent.toml"), prompt_fn: super::researcher::prompt::build, + graph_fn: super::researcher::graph::graph, }, BuiltinAgent { id: "context_scout", toml: include_str!("context_scout/agent.toml"), prompt_fn: super::context_scout::prompt::build, + graph_fn: super::context_scout::graph::graph, }, BuiltinAgent { id: "critic", toml: include_str!("critic/agent.toml"), prompt_fn: super::critic::prompt::build, + graph_fn: super::critic::graph::graph, }, BuiltinAgent { id: "vision_agent", toml: include_str!("vision_agent/agent.toml"), prompt_fn: super::vision_agent::prompt::build, + graph_fn: super::vision_agent::graph::graph, }, BuiltinAgent { id: "image_agent", toml: include_str!("image_agent/agent.toml"), prompt_fn: super::image_agent::prompt::build, + graph_fn: super::image_agent::graph::graph, }, BuiltinAgent { id: "video_agent", toml: include_str!("video_agent/agent.toml"), prompt_fn: super::video_agent::prompt::build, + graph_fn: super::video_agent::graph::graph, }, BuiltinAgent { id: "archivist", toml: include_str!("archivist/agent.toml"), prompt_fn: super::archivist::prompt::build, + graph_fn: super::archivist::graph::graph, }, BuiltinAgent { id: "goals_agent", toml: include_str!("goals_agent/agent.toml"), prompt_fn: super::goals_agent::prompt::build, + graph_fn: super::goals_agent::graph::graph, }, BuiltinAgent { id: "trigger_triage", toml: include_str!("trigger_triage/agent.toml"), prompt_fn: super::trigger_triage::prompt::build, + graph_fn: super::trigger_triage::graph::graph, }, BuiltinAgent { id: "trigger_reactor", toml: include_str!("trigger_reactor/agent.toml"), prompt_fn: super::trigger_reactor::prompt::build, + graph_fn: super::trigger_reactor::graph::graph, }, BuiltinAgent { id: "morning_briefing", toml: include_str!("morning_briefing/agent.toml"), prompt_fn: super::morning_briefing::prompt::build, + graph_fn: super::morning_briefing::graph::graph, }, BuiltinAgent { id: "summarizer", toml: include_str!("summarizer/agent.toml"), prompt_fn: super::summarizer::prompt::build, + graph_fn: super::summarizer::graph::graph, }, BuiltinAgent { id: "help", toml: include_str!("help/agent.toml"), prompt_fn: super::help::prompt::build, + graph_fn: super::help::graph::graph, }, BuiltinAgent { id: "mcp_setup", toml: include_str!("mcp_setup/agent.toml"), prompt_fn: super::mcp_setup::prompt::build, + graph_fn: super::mcp_setup::graph::graph, }, BuiltinAgent { id: "mcp_agent", toml: include_str!("mcp_agent/agent.toml"), prompt_fn: super::mcp_agent::prompt::build, + graph_fn: super::mcp_agent::graph::graph, }, BuiltinAgent { id: "skill_setup", toml: include_str!("../../skill_registry/agent/skill_setup/agent.toml"), prompt_fn: crate::openhuman::skill_registry::agent::skill_setup::prompt::build, + graph_fn: crate::openhuman::skill_registry::agent::skill_setup::graph::graph, }, BuiltinAgent { id: "skill_executor", toml: include_str!("../../skill_runtime/agent/skill_executor/agent.toml"), prompt_fn: crate::openhuman::skill_runtime::agent::skill_executor::prompt::build, + graph_fn: crate::openhuman::skill_runtime::agent::skill_executor::graph::graph, }, BuiltinAgent { id: "agent_memory", toml: include_str!("../../agent_memory/agent/agent.toml"), prompt_fn: crate::openhuman::agent_memory::agent::prompt::build, + graph_fn: crate::openhuman::agent_memory::agent::graph::graph, }, BuiltinAgent { id: "subconscious", toml: include_str!("../../subconscious/agent/agent.toml"), prompt_fn: crate::openhuman::subconscious::agent::prompt::build, + graph_fn: crate::openhuman::subconscious::agent::graph::graph, }, ]; @@ -347,6 +390,11 @@ fn parse_builtin(b: &BuiltinAgent) -> Result { def.system_prompt = PromptSource::Dynamic(b.prompt_fn); def.source = DefinitionSource::Builtin; + // Install the agent's turn-graph selection (issue #4249) — the runtime + // analogue of the prompt builder above. Default agents resolve to + // `AgentGraph::Default` (the shared sub-agent turn graph). + def.graph = (b.graph_fn)(); + // Sanity check: file layout id must match declared TOML id. This // catches copy-paste mistakes where someone forgets to update the // `id` field after duplicating a folder. diff --git a/src/openhuman/agent_registry/agents/markets_agent/graph.rs b/src/openhuman/agent_registry/agents/markets_agent/graph.rs new file mode 100644 index 000000000..6f09148e0 --- /dev/null +++ b/src/openhuman/agent_registry/agents/markets_agent/graph.rs @@ -0,0 +1,13 @@ +//! Turn graph for the `markets_agent` built-in agent. +//! +//! Uses the shared default sub-agent turn graph (`run_subagent_via_graph`) — see +//! [`crate::openhuman::agent::harness::agent_graph`]. Replace the body with +//! `AgentGraph::custom(run)` to give this agent a bespoke tinyagents graph. + +use crate::openhuman::agent::harness::agent_graph::AgentGraph; + +/// Select this agent's turn graph. This is a default agent — it uses the shared +/// default graph rather than defining its own. +pub fn graph() -> AgentGraph { + AgentGraph::Default +} diff --git a/src/openhuman/agent_registry/agents/markets_agent/mod.rs b/src/openhuman/agent_registry/agents/markets_agent/mod.rs index 8bf84783c..cedb79d0e 100644 --- a/src/openhuman/agent_registry/agents/markets_agent/mod.rs +++ b/src/openhuman/agent_registry/agents/markets_agent/mod.rs @@ -1 +1,2 @@ +pub mod graph; pub mod prompt; diff --git a/src/openhuman/agent_registry/agents/mcp_agent/graph.rs b/src/openhuman/agent_registry/agents/mcp_agent/graph.rs new file mode 100644 index 000000000..98c2ffd75 --- /dev/null +++ b/src/openhuman/agent_registry/agents/mcp_agent/graph.rs @@ -0,0 +1,13 @@ +//! Turn graph for the `mcp_agent` built-in agent. +//! +//! Uses the shared default sub-agent turn graph (`run_subagent_via_graph`) — see +//! [`crate::openhuman::agent::harness::agent_graph`]. Replace the body with +//! `AgentGraph::custom(run)` to give this agent a bespoke tinyagents graph. + +use crate::openhuman::agent::harness::agent_graph::AgentGraph; + +/// Select this agent's turn graph. This is a default agent — it uses the shared +/// default graph rather than defining its own. +pub fn graph() -> AgentGraph { + AgentGraph::Default +} diff --git a/src/openhuman/agent_registry/agents/mcp_agent/mod.rs b/src/openhuman/agent_registry/agents/mcp_agent/mod.rs index 8bf84783c..cedb79d0e 100644 --- a/src/openhuman/agent_registry/agents/mcp_agent/mod.rs +++ b/src/openhuman/agent_registry/agents/mcp_agent/mod.rs @@ -1 +1,2 @@ +pub mod graph; pub mod prompt; diff --git a/src/openhuman/agent_registry/agents/mcp_setup/graph.rs b/src/openhuman/agent_registry/agents/mcp_setup/graph.rs new file mode 100644 index 000000000..114417cb7 --- /dev/null +++ b/src/openhuman/agent_registry/agents/mcp_setup/graph.rs @@ -0,0 +1,13 @@ +//! Turn graph for the `mcp_setup` built-in agent. +//! +//! Uses the shared default sub-agent turn graph (`run_subagent_via_graph`) — see +//! [`crate::openhuman::agent::harness::agent_graph`]. Replace the body with +//! `AgentGraph::custom(run)` to give this agent a bespoke tinyagents graph. + +use crate::openhuman::agent::harness::agent_graph::AgentGraph; + +/// Select this agent's turn graph. This is a default agent — it uses the shared +/// default graph rather than defining its own. +pub fn graph() -> AgentGraph { + AgentGraph::Default +} diff --git a/src/openhuman/agent_registry/agents/mcp_setup/mod.rs b/src/openhuman/agent_registry/agents/mcp_setup/mod.rs index 8bf84783c..cedb79d0e 100644 --- a/src/openhuman/agent_registry/agents/mcp_setup/mod.rs +++ b/src/openhuman/agent_registry/agents/mcp_setup/mod.rs @@ -1 +1,2 @@ +pub mod graph; pub mod prompt; diff --git a/src/openhuman/agent_registry/agents/morning_briefing/graph.rs b/src/openhuman/agent_registry/agents/morning_briefing/graph.rs new file mode 100644 index 000000000..be9ba9a6b --- /dev/null +++ b/src/openhuman/agent_registry/agents/morning_briefing/graph.rs @@ -0,0 +1,13 @@ +//! Turn graph for the `morning_briefing` built-in agent. +//! +//! Uses the shared default sub-agent turn graph (`run_subagent_via_graph`) — see +//! [`crate::openhuman::agent::harness::agent_graph`]. Replace the body with +//! `AgentGraph::custom(run)` to give this agent a bespoke tinyagents graph. + +use crate::openhuman::agent::harness::agent_graph::AgentGraph; + +/// Select this agent's turn graph. This is a default agent — it uses the shared +/// default graph rather than defining its own. +pub fn graph() -> AgentGraph { + AgentGraph::Default +} diff --git a/src/openhuman/agent_registry/agents/morning_briefing/mod.rs b/src/openhuman/agent_registry/agents/morning_briefing/mod.rs index 8bf84783c..cedb79d0e 100644 --- a/src/openhuman/agent_registry/agents/morning_briefing/mod.rs +++ b/src/openhuman/agent_registry/agents/morning_briefing/mod.rs @@ -1 +1,2 @@ +pub mod graph; pub mod prompt; diff --git a/src/openhuman/agent_registry/agents/orchestrator/graph.rs b/src/openhuman/agent_registry/agents/orchestrator/graph.rs new file mode 100644 index 000000000..4d9997494 --- /dev/null +++ b/src/openhuman/agent_registry/agents/orchestrator/graph.rs @@ -0,0 +1,13 @@ +//! Turn graph for the `orchestrator` built-in agent. +//! +//! Uses the shared default sub-agent turn graph (`run_subagent_via_graph`) — see +//! [`crate::openhuman::agent::harness::agent_graph`]. Replace the body with +//! `AgentGraph::custom(run)` to give this agent a bespoke tinyagents graph. + +use crate::openhuman::agent::harness::agent_graph::AgentGraph; + +/// Select this agent's turn graph. This is a default agent — it uses the shared +/// default graph rather than defining its own. +pub fn graph() -> AgentGraph { + AgentGraph::Default +} diff --git a/src/openhuman/agent_registry/agents/orchestrator/mod.rs b/src/openhuman/agent_registry/agents/orchestrator/mod.rs index 8bf84783c..cedb79d0e 100644 --- a/src/openhuman/agent_registry/agents/orchestrator/mod.rs +++ b/src/openhuman/agent_registry/agents/orchestrator/mod.rs @@ -1 +1,2 @@ +pub mod graph; pub mod prompt; diff --git a/src/openhuman/agent_registry/agents/planner/graph.rs b/src/openhuman/agent_registry/agents/planner/graph.rs new file mode 100644 index 000000000..e34e242bd --- /dev/null +++ b/src/openhuman/agent_registry/agents/planner/graph.rs @@ -0,0 +1,13 @@ +//! Turn graph for the `planner` built-in agent. +//! +//! Uses the shared default sub-agent turn graph (`run_subagent_via_graph`) — see +//! [`crate::openhuman::agent::harness::agent_graph`]. Replace the body with +//! `AgentGraph::custom(run)` to give this agent a bespoke tinyagents graph. + +use crate::openhuman::agent::harness::agent_graph::AgentGraph; + +/// Select this agent's turn graph. This is a default agent — it uses the shared +/// default graph rather than defining its own. +pub fn graph() -> AgentGraph { + AgentGraph::Default +} diff --git a/src/openhuman/agent_registry/agents/planner/mod.rs b/src/openhuman/agent_registry/agents/planner/mod.rs index 8bf84783c..cedb79d0e 100644 --- a/src/openhuman/agent_registry/agents/planner/mod.rs +++ b/src/openhuman/agent_registry/agents/planner/mod.rs @@ -1 +1,2 @@ +pub mod graph; pub mod prompt; diff --git a/src/openhuman/agent_registry/agents/presentation_agent/graph.rs b/src/openhuman/agent_registry/agents/presentation_agent/graph.rs new file mode 100644 index 000000000..a42b7cbbe --- /dev/null +++ b/src/openhuman/agent_registry/agents/presentation_agent/graph.rs @@ -0,0 +1,13 @@ +//! Turn graph for the `presentation_agent` built-in agent. +//! +//! Uses the shared default sub-agent turn graph (`run_subagent_via_graph`) — see +//! [`crate::openhuman::agent::harness::agent_graph`]. Replace the body with +//! `AgentGraph::custom(run)` to give this agent a bespoke tinyagents graph. + +use crate::openhuman::agent::harness::agent_graph::AgentGraph; + +/// Select this agent's turn graph. This is a default agent — it uses the shared +/// default graph rather than defining its own. +pub fn graph() -> AgentGraph { + AgentGraph::Default +} diff --git a/src/openhuman/agent_registry/agents/presentation_agent/mod.rs b/src/openhuman/agent_registry/agents/presentation_agent/mod.rs index 8bf84783c..cedb79d0e 100644 --- a/src/openhuman/agent_registry/agents/presentation_agent/mod.rs +++ b/src/openhuman/agent_registry/agents/presentation_agent/mod.rs @@ -1 +1,2 @@ +pub mod graph; pub mod prompt; diff --git a/src/openhuman/agent_registry/agents/profile_memory_agent/graph.rs b/src/openhuman/agent_registry/agents/profile_memory_agent/graph.rs new file mode 100644 index 000000000..8f9868452 --- /dev/null +++ b/src/openhuman/agent_registry/agents/profile_memory_agent/graph.rs @@ -0,0 +1,13 @@ +//! Turn graph for the `profile_memory_agent` built-in agent. +//! +//! Uses the shared default sub-agent turn graph (`run_subagent_via_graph`) — see +//! [`crate::openhuman::agent::harness::agent_graph`]. Replace the body with +//! `AgentGraph::custom(run)` to give this agent a bespoke tinyagents graph. + +use crate::openhuman::agent::harness::agent_graph::AgentGraph; + +/// Select this agent's turn graph. This is a default agent — it uses the shared +/// default graph rather than defining its own. +pub fn graph() -> AgentGraph { + AgentGraph::Default +} diff --git a/src/openhuman/agent_registry/agents/profile_memory_agent/mod.rs b/src/openhuman/agent_registry/agents/profile_memory_agent/mod.rs index 8bf84783c..cedb79d0e 100644 --- a/src/openhuman/agent_registry/agents/profile_memory_agent/mod.rs +++ b/src/openhuman/agent_registry/agents/profile_memory_agent/mod.rs @@ -1 +1,2 @@ +pub mod graph; pub mod prompt; diff --git a/src/openhuman/agent_registry/agents/researcher/graph.rs b/src/openhuman/agent_registry/agents/researcher/graph.rs new file mode 100644 index 000000000..ea2c3d5ff --- /dev/null +++ b/src/openhuman/agent_registry/agents/researcher/graph.rs @@ -0,0 +1,13 @@ +//! Turn graph for the `researcher` built-in agent. +//! +//! Uses the shared default sub-agent turn graph (`run_subagent_via_graph`) — see +//! [`crate::openhuman::agent::harness::agent_graph`]. Replace the body with +//! `AgentGraph::custom(run)` to give this agent a bespoke tinyagents graph. + +use crate::openhuman::agent::harness::agent_graph::AgentGraph; + +/// Select this agent's turn graph. This is a default agent — it uses the shared +/// default graph rather than defining its own. +pub fn graph() -> AgentGraph { + AgentGraph::Default +} diff --git a/src/openhuman/agent_registry/agents/researcher/mod.rs b/src/openhuman/agent_registry/agents/researcher/mod.rs index 8bf84783c..cedb79d0e 100644 --- a/src/openhuman/agent_registry/agents/researcher/mod.rs +++ b/src/openhuman/agent_registry/agents/researcher/mod.rs @@ -1 +1,2 @@ +pub mod graph; pub mod prompt; diff --git a/src/openhuman/agent_registry/agents/scheduler_agent/graph.rs b/src/openhuman/agent_registry/agents/scheduler_agent/graph.rs new file mode 100644 index 000000000..e652fb4ba --- /dev/null +++ b/src/openhuman/agent_registry/agents/scheduler_agent/graph.rs @@ -0,0 +1,13 @@ +//! Turn graph for the `scheduler_agent` built-in agent. +//! +//! Uses the shared default sub-agent turn graph (`run_subagent_via_graph`) — see +//! [`crate::openhuman::agent::harness::agent_graph`]. Replace the body with +//! `AgentGraph::custom(run)` to give this agent a bespoke tinyagents graph. + +use crate::openhuman::agent::harness::agent_graph::AgentGraph; + +/// Select this agent's turn graph. This is a default agent — it uses the shared +/// default graph rather than defining its own. +pub fn graph() -> AgentGraph { + AgentGraph::Default +} diff --git a/src/openhuman/agent_registry/agents/scheduler_agent/mod.rs b/src/openhuman/agent_registry/agents/scheduler_agent/mod.rs index 8bf84783c..cedb79d0e 100644 --- a/src/openhuman/agent_registry/agents/scheduler_agent/mod.rs +++ b/src/openhuman/agent_registry/agents/scheduler_agent/mod.rs @@ -1 +1,2 @@ +pub mod graph; pub mod prompt; diff --git a/src/openhuman/agent_registry/agents/screen_awareness_agent/graph.rs b/src/openhuman/agent_registry/agents/screen_awareness_agent/graph.rs new file mode 100644 index 000000000..668578e63 --- /dev/null +++ b/src/openhuman/agent_registry/agents/screen_awareness_agent/graph.rs @@ -0,0 +1,13 @@ +//! Turn graph for the `screen_awareness_agent` built-in agent. +//! +//! Uses the shared default sub-agent turn graph (`run_subagent_via_graph`) — see +//! [`crate::openhuman::agent::harness::agent_graph`]. Replace the body with +//! `AgentGraph::custom(run)` to give this agent a bespoke tinyagents graph. + +use crate::openhuman::agent::harness::agent_graph::AgentGraph; + +/// Select this agent's turn graph. This is a default agent — it uses the shared +/// default graph rather than defining its own. +pub fn graph() -> AgentGraph { + AgentGraph::Default +} diff --git a/src/openhuman/agent_registry/agents/screen_awareness_agent/mod.rs b/src/openhuman/agent_registry/agents/screen_awareness_agent/mod.rs index 8bf84783c..cedb79d0e 100644 --- a/src/openhuman/agent_registry/agents/screen_awareness_agent/mod.rs +++ b/src/openhuman/agent_registry/agents/screen_awareness_agent/mod.rs @@ -1 +1,2 @@ +pub mod graph; pub mod prompt; diff --git a/src/openhuman/agent_registry/agents/settings_agent/graph.rs b/src/openhuman/agent_registry/agents/settings_agent/graph.rs new file mode 100644 index 000000000..c91e96753 --- /dev/null +++ b/src/openhuman/agent_registry/agents/settings_agent/graph.rs @@ -0,0 +1,13 @@ +//! Turn graph for the `settings_agent` built-in agent. +//! +//! Uses the shared default sub-agent turn graph (`run_subagent_via_graph`) — see +//! [`crate::openhuman::agent::harness::agent_graph`]. Replace the body with +//! `AgentGraph::custom(run)` to give this agent a bespoke tinyagents graph. + +use crate::openhuman::agent::harness::agent_graph::AgentGraph; + +/// Select this agent's turn graph. This is a default agent — it uses the shared +/// default graph rather than defining its own. +pub fn graph() -> AgentGraph { + AgentGraph::Default +} diff --git a/src/openhuman/agent_registry/agents/settings_agent/mod.rs b/src/openhuman/agent_registry/agents/settings_agent/mod.rs index 8bf84783c..cedb79d0e 100644 --- a/src/openhuman/agent_registry/agents/settings_agent/mod.rs +++ b/src/openhuman/agent_registry/agents/settings_agent/mod.rs @@ -1 +1,2 @@ +pub mod graph; pub mod prompt; diff --git a/src/openhuman/agent_registry/agents/skill_creator/graph.rs b/src/openhuman/agent_registry/agents/skill_creator/graph.rs new file mode 100644 index 000000000..df4ab8007 --- /dev/null +++ b/src/openhuman/agent_registry/agents/skill_creator/graph.rs @@ -0,0 +1,13 @@ +//! Turn graph for the `skill_creator` built-in agent. +//! +//! Uses the shared default sub-agent turn graph (`run_subagent_via_graph`) — see +//! [`crate::openhuman::agent::harness::agent_graph`]. Replace the body with +//! `AgentGraph::custom(run)` to give this agent a bespoke tinyagents graph. + +use crate::openhuman::agent::harness::agent_graph::AgentGraph; + +/// Select this agent's turn graph. This is a default agent — it uses the shared +/// default graph rather than defining its own. +pub fn graph() -> AgentGraph { + AgentGraph::Default +} diff --git a/src/openhuman/agent_registry/agents/skill_creator/mod.rs b/src/openhuman/agent_registry/agents/skill_creator/mod.rs index 8bf84783c..cedb79d0e 100644 --- a/src/openhuman/agent_registry/agents/skill_creator/mod.rs +++ b/src/openhuman/agent_registry/agents/skill_creator/mod.rs @@ -1 +1,2 @@ +pub mod graph; pub mod prompt; diff --git a/src/openhuman/agent_registry/agents/summarizer/graph.rs b/src/openhuman/agent_registry/agents/summarizer/graph.rs new file mode 100644 index 000000000..386e48e5b --- /dev/null +++ b/src/openhuman/agent_registry/agents/summarizer/graph.rs @@ -0,0 +1,13 @@ +//! Turn graph for the `summarizer` built-in agent. +//! +//! Uses the shared default sub-agent turn graph (`run_subagent_via_graph`) — see +//! [`crate::openhuman::agent::harness::agent_graph`]. Replace the body with +//! `AgentGraph::custom(run)` to give this agent a bespoke tinyagents graph. + +use crate::openhuman::agent::harness::agent_graph::AgentGraph; + +/// Select this agent's turn graph. This is a default agent — it uses the shared +/// default graph rather than defining its own. +pub fn graph() -> AgentGraph { + AgentGraph::Default +} diff --git a/src/openhuman/agent_registry/agents/summarizer/mod.rs b/src/openhuman/agent_registry/agents/summarizer/mod.rs index 8bf84783c..cedb79d0e 100644 --- a/src/openhuman/agent_registry/agents/summarizer/mod.rs +++ b/src/openhuman/agent_registry/agents/summarizer/mod.rs @@ -1 +1,2 @@ +pub mod graph; pub mod prompt; diff --git a/src/openhuman/agent_registry/agents/task_manager_agent/graph.rs b/src/openhuman/agent_registry/agents/task_manager_agent/graph.rs new file mode 100644 index 000000000..c45ef5f95 --- /dev/null +++ b/src/openhuman/agent_registry/agents/task_manager_agent/graph.rs @@ -0,0 +1,13 @@ +//! Turn graph for the `task_manager_agent` built-in agent. +//! +//! Uses the shared default sub-agent turn graph (`run_subagent_via_graph`) — see +//! [`crate::openhuman::agent::harness::agent_graph`]. Replace the body with +//! `AgentGraph::custom(run)` to give this agent a bespoke tinyagents graph. + +use crate::openhuman::agent::harness::agent_graph::AgentGraph; + +/// Select this agent's turn graph. This is a default agent — it uses the shared +/// default graph rather than defining its own. +pub fn graph() -> AgentGraph { + AgentGraph::Default +} diff --git a/src/openhuman/agent_registry/agents/task_manager_agent/mod.rs b/src/openhuman/agent_registry/agents/task_manager_agent/mod.rs index 8bf84783c..cedb79d0e 100644 --- a/src/openhuman/agent_registry/agents/task_manager_agent/mod.rs +++ b/src/openhuman/agent_registry/agents/task_manager_agent/mod.rs @@ -1 +1,2 @@ +pub mod graph; pub mod prompt; diff --git a/src/openhuman/agent_registry/agents/tool_maker/graph.rs b/src/openhuman/agent_registry/agents/tool_maker/graph.rs new file mode 100644 index 000000000..8c7ee278e --- /dev/null +++ b/src/openhuman/agent_registry/agents/tool_maker/graph.rs @@ -0,0 +1,13 @@ +//! Turn graph for the `tool_maker` built-in agent. +//! +//! Uses the shared default sub-agent turn graph (`run_subagent_via_graph`) — see +//! [`crate::openhuman::agent::harness::agent_graph`]. Replace the body with +//! `AgentGraph::custom(run)` to give this agent a bespoke tinyagents graph. + +use crate::openhuman::agent::harness::agent_graph::AgentGraph; + +/// Select this agent's turn graph. This is a default agent — it uses the shared +/// default graph rather than defining its own. +pub fn graph() -> AgentGraph { + AgentGraph::Default +} diff --git a/src/openhuman/agent_registry/agents/tool_maker/mod.rs b/src/openhuman/agent_registry/agents/tool_maker/mod.rs index 8bf84783c..cedb79d0e 100644 --- a/src/openhuman/agent_registry/agents/tool_maker/mod.rs +++ b/src/openhuman/agent_registry/agents/tool_maker/mod.rs @@ -1 +1,2 @@ +pub mod graph; pub mod prompt; diff --git a/src/openhuman/agent_registry/agents/tools_agent/graph.rs b/src/openhuman/agent_registry/agents/tools_agent/graph.rs new file mode 100644 index 000000000..d2f5ecc99 --- /dev/null +++ b/src/openhuman/agent_registry/agents/tools_agent/graph.rs @@ -0,0 +1,13 @@ +//! Turn graph for the `tools_agent` built-in agent. +//! +//! Uses the shared default sub-agent turn graph (`run_subagent_via_graph`) — see +//! [`crate::openhuman::agent::harness::agent_graph`]. Replace the body with +//! `AgentGraph::custom(run)` to give this agent a bespoke tinyagents graph. + +use crate::openhuman::agent::harness::agent_graph::AgentGraph; + +/// Select this agent's turn graph. This is a default agent — it uses the shared +/// default graph rather than defining its own. +pub fn graph() -> AgentGraph { + AgentGraph::Default +} diff --git a/src/openhuman/agent_registry/agents/tools_agent/mod.rs b/src/openhuman/agent_registry/agents/tools_agent/mod.rs index 8bf84783c..cedb79d0e 100644 --- a/src/openhuman/agent_registry/agents/tools_agent/mod.rs +++ b/src/openhuman/agent_registry/agents/tools_agent/mod.rs @@ -1 +1,2 @@ +pub mod graph; pub mod prompt; diff --git a/src/openhuman/agent_registry/agents/trigger_reactor/graph.rs b/src/openhuman/agent_registry/agents/trigger_reactor/graph.rs new file mode 100644 index 000000000..3aa3df7a4 --- /dev/null +++ b/src/openhuman/agent_registry/agents/trigger_reactor/graph.rs @@ -0,0 +1,13 @@ +//! Turn graph for the `trigger_reactor` built-in agent. +//! +//! Uses the shared default sub-agent turn graph (`run_subagent_via_graph`) — see +//! [`crate::openhuman::agent::harness::agent_graph`]. Replace the body with +//! `AgentGraph::custom(run)` to give this agent a bespoke tinyagents graph. + +use crate::openhuman::agent::harness::agent_graph::AgentGraph; + +/// Select this agent's turn graph. This is a default agent — it uses the shared +/// default graph rather than defining its own. +pub fn graph() -> AgentGraph { + AgentGraph::Default +} diff --git a/src/openhuman/agent_registry/agents/trigger_reactor/mod.rs b/src/openhuman/agent_registry/agents/trigger_reactor/mod.rs index 8bf84783c..cedb79d0e 100644 --- a/src/openhuman/agent_registry/agents/trigger_reactor/mod.rs +++ b/src/openhuman/agent_registry/agents/trigger_reactor/mod.rs @@ -1 +1,2 @@ +pub mod graph; pub mod prompt; diff --git a/src/openhuman/agent_registry/agents/trigger_triage/graph.rs b/src/openhuman/agent_registry/agents/trigger_triage/graph.rs new file mode 100644 index 000000000..32ec4f66c --- /dev/null +++ b/src/openhuman/agent_registry/agents/trigger_triage/graph.rs @@ -0,0 +1,13 @@ +//! Turn graph for the `trigger_triage` built-in agent. +//! +//! Uses the shared default sub-agent turn graph (`run_subagent_via_graph`) — see +//! [`crate::openhuman::agent::harness::agent_graph`]. Replace the body with +//! `AgentGraph::custom(run)` to give this agent a bespoke tinyagents graph. + +use crate::openhuman::agent::harness::agent_graph::AgentGraph; + +/// Select this agent's turn graph. This is a default agent — it uses the shared +/// default graph rather than defining its own. +pub fn graph() -> AgentGraph { + AgentGraph::Default +} diff --git a/src/openhuman/agent_registry/agents/trigger_triage/mod.rs b/src/openhuman/agent_registry/agents/trigger_triage/mod.rs index 8bf84783c..cedb79d0e 100644 --- a/src/openhuman/agent_registry/agents/trigger_triage/mod.rs +++ b/src/openhuman/agent_registry/agents/trigger_triage/mod.rs @@ -1 +1,2 @@ +pub mod graph; pub mod prompt; diff --git a/src/openhuman/agent_registry/agents/video_agent/graph.rs b/src/openhuman/agent_registry/agents/video_agent/graph.rs new file mode 100644 index 000000000..e9c2e0805 --- /dev/null +++ b/src/openhuman/agent_registry/agents/video_agent/graph.rs @@ -0,0 +1,13 @@ +//! Turn graph for the `video_agent` built-in agent. +//! +//! Uses the shared default sub-agent turn graph (`run_subagent_via_graph`) — see +//! [`crate::openhuman::agent::harness::agent_graph`]. Replace the body with +//! `AgentGraph::custom(run)` to give this agent a bespoke tinyagents graph. + +use crate::openhuman::agent::harness::agent_graph::AgentGraph; + +/// Select this agent's turn graph. This is a default agent — it uses the shared +/// default graph rather than defining its own. +pub fn graph() -> AgentGraph { + AgentGraph::Default +} diff --git a/src/openhuman/agent_registry/agents/video_agent/mod.rs b/src/openhuman/agent_registry/agents/video_agent/mod.rs index 8bf84783c..cedb79d0e 100644 --- a/src/openhuman/agent_registry/agents/video_agent/mod.rs +++ b/src/openhuman/agent_registry/agents/video_agent/mod.rs @@ -1 +1,2 @@ +pub mod graph; pub mod prompt; diff --git a/src/openhuman/agent_registry/agents/vision_agent/graph.rs b/src/openhuman/agent_registry/agents/vision_agent/graph.rs new file mode 100644 index 000000000..749f571a2 --- /dev/null +++ b/src/openhuman/agent_registry/agents/vision_agent/graph.rs @@ -0,0 +1,13 @@ +//! Turn graph for the `vision_agent` built-in agent. +//! +//! Uses the shared default sub-agent turn graph (`run_subagent_via_graph`) — see +//! [`crate::openhuman::agent::harness::agent_graph`]. Replace the body with +//! `AgentGraph::custom(run)` to give this agent a bespoke tinyagents graph. + +use crate::openhuman::agent::harness::agent_graph::AgentGraph; + +/// Select this agent's turn graph. This is a default agent — it uses the shared +/// default graph rather than defining its own. +pub fn graph() -> AgentGraph { + AgentGraph::Default +} diff --git a/src/openhuman/agent_registry/agents/vision_agent/mod.rs b/src/openhuman/agent_registry/agents/vision_agent/mod.rs index 8bf84783c..cedb79d0e 100644 --- a/src/openhuman/agent_registry/agents/vision_agent/mod.rs +++ b/src/openhuman/agent_registry/agents/vision_agent/mod.rs @@ -1 +1,2 @@ +pub mod graph; pub mod prompt; diff --git a/src/openhuman/channels/runtime/dispatch/mod.rs b/src/openhuman/channels/runtime/dispatch/mod.rs index 2071a74f4..df738b88f 100644 --- a/src/openhuman/channels/runtime/dispatch/mod.rs +++ b/src/openhuman/channels/runtime/dispatch/mod.rs @@ -127,6 +127,7 @@ mod scoping_tests { delegate_name: None, agent_tier: crate::openhuman::agent::harness::definition::AgentTier::Worker, source: DefinitionSource::Builtin, + graph: Default::default(), } } diff --git a/src/openhuman/config/schema/learning.rs b/src/openhuman/config/schema/learning.rs index d91ec684b..032f79deb 100644 --- a/src/openhuman/config/schema/learning.rs +++ b/src/openhuman/config/schema/learning.rs @@ -106,25 +106,6 @@ pub struct LearningConfig { #[serde(default = "default_true")] pub stm_recall_enabled: bool, - /// Use the rolling segment recap as the compaction text for evicted turns - /// (Phase 1.5 — unified compaction). - /// - /// When `true`, the [`ContextManager`]'s autocompaction summarizer is - /// wrapped with a `SegmentRecapSummarizer` that first tries to obtain the - /// current open segment's rolling recap from the `ArchivistHook` and uses - /// it as the replacement text for the evicted head. If the rolling recap - /// is unavailable (no archivist, no open segment, LLM failure, flag off) - /// the inner `ProviderSummarizer` runs as before — the live prompt is - /// NEVER left over-budget regardless of the recap path's health. - /// - /// Default: `true`. Set to `false` to revert to the standalone - /// `ProviderSummarizer` path (today's behaviour, Phase 1.5 completely - /// absent from the hot path). - /// - /// Override via `OPENHUMAN_LEARNING_UNIFIED_COMPACTION_ENABLED=0|1`. - #[serde(default = "default_true")] - pub unified_compaction_enabled: bool, - /// How often the periodic rebuild loop runs in seconds. Default: 1800 (30 minutes). #[serde(default = "default_rebuild_interval_secs")] pub rebuild_interval_secs: u64, @@ -186,7 +167,6 @@ impl Default for LearningConfig { rebuild_interval_secs: default_rebuild_interval_secs(), episodic_capture_enabled: default_true(), stm_recall_enabled: default_true(), - unified_compaction_enabled: default_true(), explicit_preferences_enabled: default_true(), goals_enrichment_enabled: default_true(), } diff --git a/src/openhuman/config/schema/load/env_overlay.rs b/src/openhuman/config/schema/load/env_overlay.rs index 6df6fd690..96f8ddff3 100644 --- a/src/openhuman/config/schema/load/env_overlay.rs +++ b/src/openhuman/config/schema/load/env_overlay.rs @@ -633,14 +633,6 @@ impl Config { self.learning.stm_recall_enabled = enabled; } } - if let Some(flag) = env.get("OPENHUMAN_LEARNING_UNIFIED_COMPACTION_ENABLED") { - if let Some(enabled) = parse_env_bool( - "OPENHUMAN_LEARNING_UNIFIED_COMPACTION_ENABLED", - flag.as_str(), - ) { - self.learning.unified_compaction_enabled = enabled; - } - } } fn apply_memory_tree_env(&mut self, env: &E) { diff --git a/src/openhuman/context/README.md b/src/openhuman/context/README.md index 64c06b335..27b73d43b 100644 --- a/src/openhuman/context/README.md +++ b/src/openhuman/context/README.md @@ -1,6 +1,8 @@ # context -Global context management for agent sessions: the single home for everything that shapes what an LLM sees during a conversation. It owns (1) **system-prompt assembly** (via a re-export of `agent::prompts` plus a bespoke channel-runtime prompt builder), (2) **mechanical history reduction** — a layered pipeline (tool-result budget → trim → microcompact → autocompact signal → session-memory trigger) that keeps the in-flight conversation inside the provider's context window, and (3) **summarization execution** — dispatching the LLM compaction call when the pipeline asks for autocompaction. Agents hold one `ContextManager` per session; all shared logic lives here so new agent archetypes and delegation tools do not re-wire any of it. This is a **pure logic / state-tracking domain** — no RPC controllers, no agent tools, no event-bus subscribers, no persisted store. +Global context management for agent sessions: the home for system-prompt assembly, per-session context bookkeeping (utilisation stats, tool-result budgeting, session-memory triggers), and prompt-cache diagnostics. Agents hold one `ContextManager` per session. This is a **pure logic / state-tracking domain** — no RPC controllers, no agent tools, no event-bus subscribers, no persisted store. + +> **Status (#4249): live history reduction/summarization moved to the tinyagents graph.** The in-turn compaction that used to live here — `ContextManager::reduce_before_call`, the `Summarizer` trait, `ProviderSummarizer`, and `SegmentRecapSummarizer` — has been **removed**. Folding an over-budget transcript into a summary now runs as `ContextCompressionMiddleware` (+ `MessageTrimMiddleware` backstop) inside `run_turn_via_tinyagents_shared`, backed by `tinyagents::summarize::ProviderModelSummarizer`. `ContextGuard`/`ContextPipeline`/`microcompact` remain only as the data model behind `ContextManager::stats()` (the utilisation footer) and session-memory bookkeeping; they no longer drive a reduction pass. Some rows below still describe the removed machinery and are retained for history. ## Responsibilities @@ -18,17 +20,17 @@ Global context management for agent sessions: the single home for everything tha | File | Role | | --- | --- | | `src/openhuman/context/mod.rs` | Module docstring + `pub mod` decls + `pub use` re-exports. Export-focused, no logic. | -| `src/openhuman/context/manager.rs` | `ContextManager` — per-session handle agents hold. Owns the default prompt builder, the pipeline, the summarizer `Arc`, and budget/markdown config. Entry point `reduce_before_call` maps `PipelineOutcome` → `ReductionOutcome` and dispatches the summarizer. | +| `src/openhuman/context/manager.rs` | `ContextManager` — per-session handle agents hold. Owns the default prompt builder, the pipeline (stats/session-memory), and budget/markdown config. Surfaces `build_system_prompt`, `stats()`, tool-result budget, and session-memory triggers. (The former `reduce_before_call`/summarizer dispatch was removed in #4249.) | | `src/openhuman/context/pipeline.rs` | `ContextPipeline` orchestrator. Runs the guard, microcompact, autocompact signalling, and session-memory bookkeeping. Owns `ContextGuard` + a shared `SessionMemoryHandle` (`Arc>`). Pure — issues no LLM calls. | | `src/openhuman/context/guard.rs` | `ContextGuard` — pre-inference utilisation check (soft threshold 0.90, hard 0.95) with a 3-strike compaction circuit breaker. No-op when context window is unknown. | | `src/openhuman/context/microcompact.rs` | Stage 3. `microcompact()` clears bodies of older `ToolResults` envelopes (keeping the N most recent), idempotent, invariant-preserving. | | `src/openhuman/context/tool_result_budget.rs` | Stage 1. `apply_tool_result_budget()` — UTF-8-safe per-result byte cap applied inline at tool-execution time (default 16 KiB) with a truncation marker. | -| `src/openhuman/context/summarizer.rs` | `Summarizer` async trait + default `ProviderSummarizer` (wraps `Arc`). Replaces the head of history with one system summary message, keeping `keep_recent` tail messages; `snap_split_forward` (shared `pub(super)`) never splits a tool-call pair. | -| `src/openhuman/context/segment_recap_summarizer.rs` | `SegmentRecapSummarizer` — wraps an inner `Summarizer`, prefers the archivist's rolling segment recap, soft-falls-back to the inner summarizer when no recap is available. Read-only against the archivist. | +| `src/openhuman/context/summarizer.rs` | **Removed (#4249).** Live summarization moved to `tinyagents::summarize` (`ProviderModelSummarizer`); the summarizer system prompt was relocated there. | +| `src/openhuman/context/segment_recap_summarizer.rs` | **Removed (#4249).** The archivist-recap-backed compaction wrapper is gone; the archivist still produces durable segment recaps on its own post-turn path. | | `src/openhuman/context/session_memory.rs` | `SessionMemoryState` / `SessionMemoryConfig` — threshold-gated `should_extract` decision (token growth + tool calls + turns must all cross) and extraction bookkeeping. Holds `ARCHIVIST_EXTRACTION_PROMPT`. State-tracking only; does not spawn the archivist. | | `src/openhuman/context/prompt.rs` | Compat shim — `pub use crate::openhuman::agent::prompts::*`. Prompt rendering moved to `agent::prompts`; this keeps `context::prompt::...` as a stable import path. | | `src/openhuman/context/channels_prompt.rs` | Bespoke free-function `build_system_prompt(...)` for channel runtimes (Discord/Slack/Telegram/…). Byte-stable for prefix-cache hits; injects OpenClaw bootstrap files (`SOUL.md`, `IDENTITY.md`, optional `PROFILE.md`/`MEMORY.md`), tools, safety, skills, runtime, and channel-capabilities sections. | -| `src/openhuman/context/manager_tests.rs` · `summarizer_tests.rs` · `segment_recap_summarizer_tests.rs` | Sibling test suites wired via `#[cfg(test)] #[path = ...] mod tests`. Other files use inline `#[cfg(test)] mod tests`. | +| `src/openhuman/context/manager_tests.rs` | Sibling test suite wired via `#[cfg(test)] #[path = ...] mod tests`. Other files use inline `#[cfg(test)] mod tests`. (`summarizer_tests.rs` / `segment_recap_summarizer_tests.rs` removed in #4249.) | ## Public surface @@ -73,11 +75,9 @@ The durable long-term substrate session-memory targets is the workspace `MEMORY. - `crate::openhuman::agent::harness::archivist::ArchivistHook` — `SegmentRecapSummarizer` reads the rolling segment recap (read-only) from the archivist. - `crate::openhuman::skills::Skill` — `channels_prompt.rs` renders the available-skills section. - `crate::openhuman::util::floor_char_boundary` — UTF-8-safe truncation in `tool_result_budget`. -- `crate::openhuman::memory` / `memory_store` — used only in the `segment_recap_summarizer` tests (`ChatPrompt`, `fts5`, `segments`). - ## Used by -- **`agent::harness`** — the primary consumer: `session/builder.rs` constructs the `ContextManager` (and chooses `ProviderSummarizer` vs `SegmentRecapSummarizer` based on `config.learning.unified_compaction_enabled`); `session/turn.rs` calls `reduce_before_call`, drives the session-memory counters, and spawns the archivist extraction when `should_extract_session_memory` says so; `session/runtime.rs`, `fork_context.rs`, `tool_loop.rs`, `subagent_runner/*`, and `tool_filter.rs` consume the prompt builder and reduction surface. +- **`agent::harness`** — the primary consumer: `session/builder.rs` constructs the `ContextManager`; `session/turn.rs` drives the session-memory counters and spawns the archivist extraction when `should_extract_session_memory` says so; `fork_context.rs`, `subagent_runner/*`, and `tool_filter.rs` consume the prompt builder and stats/budget surface. (History reduction/summarization moved to the tinyagents graph in #4249 — see the status banner above; `reduce_before_call`/`ProviderSummarizer`/`SegmentRecapSummarizer`/`unified_compaction_enabled` are removed.) - **`agent::agents/*/prompt.rs`** — every archetype prompt module pulls prompt sections/builder through `context::prompt`. - **`channels`** — `channels/runtime/startup.rs` (and channel prompt/identity tests) call `channels_prompt::build_system_prompt`. - **`agent::dispatcher`, `agent::triage`, `agent::tools` (spawn_subagent / spawn_parallel / spawn_worker_thread), `learning::prompt_sections`, `memory_tools::prompt`, `tools::orchestrator_tools`, `composio::ops`** — consume the prompt-building surface. diff --git a/src/openhuman/context/manager.rs b/src/openhuman/context/manager.rs index 82fe4164f..d904c0f11 100644 --- a/src/openhuman/context/manager.rs +++ b/src/openhuman/context/manager.rs @@ -10,14 +10,12 @@ //! capabilities sections — pass their own via //! [`ContextManager::build_system_prompt_with`]. //! -//! 2. **Mechanical context reduction** — a [`ContextPipeline`] with its -//! guard, microcompact stage, and session-memory tracker. -//! -//! 3. **LLM summarization dispatch** — an `Arc` that -//! gets called when the pipeline reports -//! [`PipelineOutcome::AutocompactionRequested`]. The manager records -//! the summarizer outcome on the guard's circuit breaker so -//! repeated failures don't loop forever. +//! 2. **Context bookkeeping** — a [`ContextPipeline`] with its guard +//! (utilisation stats), tool-result budget, and session-memory +//! tracker. Live history reduction/summarization moved to the +//! tinyagents graph (`ContextCompressionMiddleware` + +//! `MessageTrimMiddleware`, issue #4249); this manager no longer runs +//! an in-turn summarizer. //! //! # What it doesn't own //! @@ -28,52 +26,13 @@ //! [`ContextManager::should_extract_session_memory`] so `turn.rs` can //! gate its existing `spawn_subagent` call. -use std::sync::Arc; - -use super::pipeline::{ - ContextPipeline, ContextPipelineConfig, PipelineOutcome, SessionMemoryHandle, -}; +use super::pipeline::{ContextPipeline, ContextPipelineConfig, SessionMemoryHandle}; use super::prompt::{PromptContext, SystemPromptBuilder}; use super::session_memory::SessionMemoryConfig; -use super::summarizer::{Summarizer, SummaryStats}; use crate::openhuman::config::ContextConfig; -use crate::openhuman::inference::provider::{ConversationMessage, UsageInfo}; +use crate::openhuman::inference::provider::UsageInfo; use anyhow::Result; -/// Outcome of a reduction pass driven by [`ContextManager::reduce_before_call`]. -/// -/// This is a slightly wider shape than [`PipelineOutcome`] because the -/// manager surfaces the result of the summarizer LLM call as a -/// first-class variant — the pipeline alone can only return -/// `AutocompactionRequested`. -#[derive(Debug, Clone)] -pub enum ReductionOutcome { - /// No stage fired — budget is healthy and history was untouched. - NoOp, - /// The pipeline's microcompact stage cleared one or more older - /// tool-result envelopes. The history has been mutated in place. - Microcompacted { - envelopes_cleared: usize, - entries_cleared: usize, - bytes_freed: usize, - }, - /// The pipeline asked for summarization and the summarizer - /// successfully rewrote the head of the history. Contains the - /// summarizer's own stats for logging / RPC surfacing. - Summarized(SummaryStats), - /// The summarizer was asked to run but failed — the guard's - /// compaction circuit breaker has been nudged. If this happens - /// three times in a row the breaker trips and subsequent calls - /// return [`ReductionOutcome::Exhausted`]. - SummarizationFailed { utilisation_pct: u8, reason: String }, - /// The circuit breaker is tripped and the context is still above - /// the hard limit — the agent turn should abort. - Exhausted { utilisation_pct: u8, reason: String }, - /// Autocompaction was requested but disabled by config. The - /// caller is expected to surface this via the guard directly. - NotAttempted { utilisation_pct: u8 }, -} - /// Read-only snapshot of per-session context state. Returned by /// [`ContextManager::stats`] for observability and the optional /// `context.get_stats` RPC. @@ -94,21 +53,16 @@ pub struct ContextStats { /// at session start; lives for the whole lifetime of the `Agent`. pub struct ContextManager { pipeline: ContextPipeline, - summarizer: Arc, - /// Model used for the summarization LLM call. Defaults to the - /// session's main model; can be overridden via - /// [`ContextConfig::summarizer_model`] when the user wants a - /// cheaper model for compaction. - summarizer_model: String, /// The default system-prompt builder used by /// [`ContextManager::build_system_prompt`]. Held by value so the /// agent's construction-time builder configuration survives the /// move into the manager. default_prompt_builder: SystemPromptBuilder, - /// Whether the entire module is enabled. When `false`, - /// [`ContextManager::reduce_before_call`] always returns `NoOp`. - /// Useful for tests and debugging; see - /// [`ContextConfig::enabled`]. + /// Whether the entire module is enabled. Useful for tests and + /// debugging; see [`ContextConfig::enabled`]. Live history reduction + /// now runs in the tinyagents graph (`ContextCompressionMiddleware` + + /// `MessageTrimMiddleware`, issue #4249); this flag only gates the + /// manager's own bookkeeping surfaces. enabled: bool, /// Per-tool-result byte cap applied inline at tool-execution time. /// Stored on the manager (rather than on the agent directly) so @@ -125,29 +79,34 @@ pub struct ContextManager { /// kill-switch lives here so every caller reads one source of truth. /// See [`ContextConfig::compaction_enabled`]. compaction_enabled: bool, + /// Number of most-recent tool results kept verbatim by the microcompact + /// middleware; `0` when microcompact is disabled. Read by the tinyagents + /// turn to configure `MicrocompactMiddleware`. + microcompact_keep_recent: usize, /// When `true`, the harness runs a mandatory first-turn context /// collection pass before the orchestrator LLM runs. Read once at /// session construction so it only affects newly started threads. /// See [`ContextConfig::super_context_enabled`]. super_context_enabled: bool, + /// When `true`, the tinyagents turn installs the LLM summarization step + /// (`ContextCompressionMiddleware`). Gated by both `[context].enabled` and + /// `[context].autocompact_enabled` so a diagnostic/test opt-out doesn't spend + /// summarizer tokens or rewrite history. See [`ContextConfig::autocompact_enabled`]. + autocompact_enabled: bool, } impl ContextManager { /// Construct a manager for a session. /// /// * `config` — the loaded [`ContextConfig`] section. - /// * `summarizer` — typically a [`super::ProviderSummarizer`] - /// wrapping the session's provider, but tests pass a mock. - /// * `main_model` — the agent's main model; used as the - /// summarizer model unless `config.summarizer_model` overrides. /// * `default_prompt_builder` — the builder [`build_system_prompt`] /// calls. For most agents this is `SystemPromptBuilder::with_defaults()`. - pub fn new( - config: &ContextConfig, - summarizer: Arc, - main_model: String, - default_prompt_builder: SystemPromptBuilder, - ) -> Self { + /// + /// The manager no longer owns a summarizer: live history reduction moved + /// to the tinyagents graph (issue #4249). What remains here is the system + /// prompt, the stats/utilisation surface, tool-result budgeting, and + /// session-memory bookkeeping. + pub fn new(config: &ContextConfig, default_prompt_builder: SystemPromptBuilder) -> Self { // Map ContextConfig into the mechanical pipeline's own config // struct. Session-memory thresholds flow through unchanged. let pipeline_config = ContextPipelineConfig { @@ -161,18 +120,22 @@ impl ContextManager { }, }; - let summarizer_model = config.summarizer_model.clone().unwrap_or(main_model); - Self { pipeline: ContextPipeline::new(pipeline_config), - summarizer, - summarizer_model, default_prompt_builder, enabled: config.enabled, tool_result_budget_bytes: config.tool_result_budget_bytes, prefer_markdown_tool_output: config.prefer_markdown_tool_output, compaction_enabled: config.compaction_enabled, + microcompact_keep_recent: if config.microcompact_enabled { + config.microcompact_keep_recent + } else { + 0 + }, super_context_enabled: config.super_context_enabled, + // Summarization is off when the whole context system is disabled OR + // autocompaction specifically is turned off. + autocompact_enabled: config.enabled && config.autocompact_enabled, } } @@ -182,6 +145,13 @@ impl ContextManager { self.prefer_markdown_tool_output } + /// Number of most-recent tool results the microcompact middleware keeps + /// verbatim; `0` when microcompact is disabled. Read by the tinyagents turn + /// to configure `MicrocompactMiddleware`. + pub fn microcompact_keep_recent(&self) -> usize { + self.microcompact_keep_recent + } + /// Byte budget for an individual tool result before the context /// pipeline's inline truncation stage fires. Agents read this when /// a tool returns to apply the cap before the result enters @@ -205,6 +175,15 @@ impl ContextManager { self.super_context_enabled } + /// Whether the tinyagents turn should install the LLM summarization step. + /// `false` when `[context].enabled = false` or `autocompact_enabled = false` + /// — the diagnostic/test opt-outs the legacy pipeline honored before + /// requesting autocompaction. Read by the chat turn when building + /// `TurnContextMiddleware`. + pub fn autocompact_enabled(&self) -> bool { + self.autocompact_enabled + } + /// Force-disable the first-turn super-context pass for this session, /// regardless of the config default. Used by non-interactive orchestrator /// builds (e.g. read-only model-council jurors) where a scout pass would add @@ -284,7 +263,6 @@ impl ContextManager { /// automatically, so no boundary marker is emitted. pub fn build_system_prompt(&self, ctx: &PromptContext<'_>) -> Result { let prompt = self.default_prompt_builder.build(ctx)?; - self.warn_if_cache_unstable(&prompt); Ok(prompt) } @@ -301,98 +279,9 @@ impl ContextManager { ctx: &PromptContext<'_>, ) -> Result { let prompt = builder.build(ctx)?; - self.warn_if_cache_unstable(&prompt); Ok(prompt) } - /// Cache-aligner (Stage 1a sibling, warn-only): flag volatile tokens in - /// the cache-hot system prompt that would silently break the provider - /// KV-cache prefix. Never mutates the prompt. Gated on the compaction - /// kill-switch so disabling compaction also silences this diagnostic. - fn warn_if_cache_unstable(&self, prompt: &str) { - if self.compaction_enabled { - crate::openhuman::agent::harness::compaction::cache_align::warn_if_volatile(prompt); - } - } - - // ─── Reduction ───────────────────────────────────────────────── - - /// Run the reduction chain against `history` before a provider - /// call. Cheap when the guard is healthy; executes the - /// summarization LLM call internally when the pipeline asks for - /// autocompaction. - /// - /// This is the single reduction entry point — agents call it once - /// before every provider hit and map the returned - /// [`ReductionOutcome`] into their own logging / abort logic. - pub async fn reduce_before_call( - &mut self, - history: &mut Vec, - ) -> Result { - if !self.enabled { - return Ok(ReductionOutcome::NoOp); - } - - match self.pipeline.run_before_call(history) { - PipelineOutcome::NoOp => Ok(ReductionOutcome::NoOp), - - PipelineOutcome::Microcompacted(stats) => Ok(ReductionOutcome::Microcompacted { - envelopes_cleared: stats.envelopes_cleared, - entries_cleared: stats.entries_cleared, - bytes_freed: stats.bytes_freed, - }), - - PipelineOutcome::ContextExhausted { - utilisation_pct, - reason, - } => Ok(ReductionOutcome::Exhausted { - utilisation_pct, - reason, - }), - - PipelineOutcome::AutocompactionDisabled { utilisation_pct } => { - Ok(ReductionOutcome::NotAttempted { utilisation_pct }) - } - - PipelineOutcome::AutocompactionRequested { utilisation_pct } => { - // Dispatch the summarizer. If it succeeds we reset the - // guard's circuit breaker so a prior string of failures - // doesn't leave us permanently disabled after a good - // run. On failure, we nudge the breaker — three - // consecutive failures trip it and we return - // `Exhausted` the next time the guard is checked. - tracing::info!( - utilisation_pct, - model = %self.summarizer_model, - "[context::manager] dispatching autocompaction summarizer" - ); - match self - .summarizer - .summarize(history, &self.summarizer_model) - .await - { - Ok(stats) => { - self.pipeline.guard.record_compaction_success(); - Ok(ReductionOutcome::Summarized(stats)) - } - Err(e) => { - let reason = e.to_string(); - tracing::warn!( - utilisation_pct, - error = %reason, - "[context::manager] summarizer failed — nudging circuit breaker" - ); - self.pipeline.guard.record_compaction_failure(); - Ok(ReductionOutcome::SummarizationFailed { - utilisation_pct, - reason, - }) - } - } - } - } - } - // ─── Observability ───────────────────────────────────────────── /// Read-only snapshot of the current budget state. diff --git a/src/openhuman/context/manager_tests.rs b/src/openhuman/context/manager_tests.rs index 0f802afa4..ed677e3ff 100644 --- a/src/openhuman/context/manager_tests.rs +++ b/src/openhuman/context/manager_tests.rs @@ -1,307 +1,26 @@ +//! Tests for `ContextManager`. +//! +//! History reduction/summarization moved to the tinyagents graph in #4249 +//! (`ContextCompressionMiddleware` + `tinyagents::summarize::ProviderModelSummarizer`), +//! so the old `reduce_before_call` / `Summarizer` / `ReductionOutcome` suite is +//! gone. `ContextManager` is now a pure state-tracking handle: utilisation +//! stats, tool-result budget config, microcompact knobs, and session-memory +//! bookkeeping. These tests cover that surviving surface. + use super::*; -use crate::openhuman::inference::provider::{ChatMessage, ToolCall, ToolResultMessage}; -use async_trait::async_trait; -use std::sync::Mutex; +use crate::openhuman::inference::provider::UsageInfo; -fn user(s: &str) -> ConversationMessage { - ConversationMessage::Chat(ChatMessage::user(s)) +fn manager_with_config(config: &ContextConfig) -> ContextManager { + ContextManager::new(config, SystemPromptBuilder::with_defaults()) } -fn call(id: &str) -> ConversationMessage { - ConversationMessage::AssistantToolCalls { - text: None, - tool_calls: vec![ToolCall { - id: id.into(), - name: "t".into(), - arguments: "{}".into(), - extra_content: None, - }], - reasoning_content: None, - extra_metadata: None, - } -} - -fn result(id: &str, body: &str) -> ConversationMessage { - ConversationMessage::ToolResults(vec![ToolResultMessage { - tool_call_id: id.into(), - content: body.into(), - }]) -} - -/// Mock summarizer that records how many times it was called and -/// can be configured to succeed or fail. -struct MockSummarizer { - calls: Mutex, - should_fail: bool, -} - -impl MockSummarizer { - fn ok() -> Arc { - Arc::new(Self { - calls: Mutex::new(0), - should_fail: false, - }) - } - fn failing() -> Arc { - Arc::new(Self { - calls: Mutex::new(0), - should_fail: true, - }) - } - fn call_count(&self) -> usize { - *self.calls.lock().unwrap() - } -} - -#[async_trait] -impl Summarizer for MockSummarizer { - async fn summarize( - &self, - history: &mut Vec, - _model: &str, - ) -> Result { - *self.calls.lock().unwrap() += 1; - if self.should_fail { - anyhow::bail!("mock failure"); - } - // Rewrite the history to a single system summary to - // simulate a successful reduction. - let removed = history.len(); - history.clear(); - history.push(ConversationMessage::Chat(ChatMessage::system( - "mock summary", - ))); - Ok(SummaryStats { - messages_removed: removed, - approx_tokens_freed: 1_000, - summary_chars: 12, - }) - } -} - -fn manager_with(summarizer: Arc) -> ContextManager { - let config = ContextConfig::default(); - ContextManager::new( - &config, - summarizer, - "test-model".into(), - SystemPromptBuilder::with_defaults(), - ) -} - -#[tokio::test] -async fn reduce_returns_noop_when_guard_is_healthy() { - let summarizer = MockSummarizer::ok(); - let mut manager = manager_with(summarizer.clone()); - - // Low utilisation — guard says ok, pipeline is a no-op. - manager.record_usage(&UsageInfo { - input_tokens: 5_000, - output_tokens: 500, - context_window: 100_000, - ..Default::default() - }); - - let mut history = vec![user("hi")]; - let outcome = manager.reduce_before_call(&mut history).await.unwrap(); - - assert!(matches!(outcome, ReductionOutcome::NoOp)); - assert_eq!(summarizer.call_count(), 0); -} - -#[tokio::test] -async fn reduce_surfaces_microcompact_without_calling_summarizer() { - let summarizer = MockSummarizer::ok(); - let mut manager = manager_with(summarizer.clone()); - - // Push utilisation above the 90% soft threshold. - manager.record_usage(&UsageInfo { - input_tokens: 92_000, - output_tokens: 4_000, - context_window: 100_000, - ..Default::default() - }); - - // Build a history with several older tool-result envelopes - // that microcompact can clear — the default keep_recent is - // DEFAULT_KEEP_RECENT_TOOL_RESULTS (5), so include at least - // 7 pairs so the older ones are eligible. - let mut history = vec![ - call("t1"), - result("t1", &"x".repeat(5_000)), - call("t2"), - result("t2", &"x".repeat(5_000)), - call("t3"), - result("t3", "a"), - call("t4"), - result("t4", "b"), - call("t5"), - result("t5", "c"), - call("t6"), - result("t6", "d"), - call("t7"), - result("t7", "e"), - ]; - let outcome = manager.reduce_before_call(&mut history).await.unwrap(); - - match outcome { - ReductionOutcome::Microcompacted { - envelopes_cleared, .. - } => { - assert!(envelopes_cleared > 0); - } - other => panic!("expected Microcompacted, got {other:?}"), - } - assert_eq!( - summarizer.call_count(), - 0, - "microcompact must not invoke summarizer" - ); -} - -#[tokio::test] -async fn reduce_dispatches_summarizer_and_records_success() { - let summarizer = MockSummarizer::ok(); - let mut manager = manager_with(summarizer.clone()); - - manager.record_usage(&UsageInfo { - input_tokens: 92_000, - output_tokens: 4_000, - context_window: 100_000, - ..Default::default() - }); - - // History with no old tool-result envelopes — microcompact - // has nothing to clear, so the pipeline signals - // AutocompactionRequested and the manager calls the summarizer. - let mut history = vec![user("one"), user("two"), user("three")]; - let outcome = manager.reduce_before_call(&mut history).await.unwrap(); - - match outcome { - ReductionOutcome::Summarized(stats) => { - assert_eq!(stats.messages_removed, 3); - } - other => panic!("expected Summarized, got {other:?}"), - } - assert_eq!(summarizer.call_count(), 1); - assert_eq!( - history.len(), - 1, - "mock replaced history with a single summary msg" - ); - // Guard breaker should NOT be tripped on success. - assert!(!manager.pipeline.guard.is_compaction_disabled()); -} - -#[tokio::test] -async fn summarizer_failure_trips_breaker_after_three_tries() { - let summarizer = MockSummarizer::failing(); - let mut manager = manager_with(summarizer); - - manager.record_usage(&UsageInfo { - input_tokens: 92_000, - output_tokens: 4_000, - context_window: 100_000, - ..Default::default() - }); - - // Try three times — each call sends the pipeline into - // AutocompactionRequested, the mock summarizer fails, and - // the breaker nudges forward. The fourth call should report - // Exhausted because the breaker is tripped. - for _ in 0..3 { - let mut history = vec![user("a"), user("b"), user("c")]; - let outcome = manager.reduce_before_call(&mut history).await.unwrap(); - match outcome { - ReductionOutcome::SummarizationFailed { .. } => {} - other => panic!("expected SummarizationFailed, got {other:?}"), - } - } - assert!(manager.pipeline.guard.is_compaction_disabled()); - - // Nudge the guard above the hard limit so the next pipeline - // pass returns ContextExhausted. - manager.record_usage(&UsageInfo { - input_tokens: 96_000, - output_tokens: 2_000, - context_window: 100_000, - ..Default::default() - }); - let mut history = vec![user("x")]; - let outcome = manager.reduce_before_call(&mut history).await.unwrap(); - assert!(matches!(outcome, ReductionOutcome::Exhausted { .. })); -} - -#[tokio::test] -async fn disabled_autocompact_returns_not_attempted() { - let summarizer = MockSummarizer::ok(); - let mut config = ContextConfig::default(); - // Keep master switch on but disable just the autocompact stage - // so the pipeline routes through AutocompactionDisabled instead - // of NoOp. - config.autocompact_enabled = false; - let mut manager = ContextManager::new( - &config, - summarizer.clone(), - "test-model".into(), - SystemPromptBuilder::with_defaults(), - ); - - manager.record_usage(&UsageInfo { - input_tokens: 92_000, - output_tokens: 4_000, - context_window: 100_000, - ..Default::default() - }); - - // No old tool-result envelopes — microcompact cannot free - // anything, so the pipeline lands in the autocompact branch. - let mut history = vec![user("one"), user("two"), user("three")]; - let outcome = manager.reduce_before_call(&mut history).await.unwrap(); - - match outcome { - ReductionOutcome::NotAttempted { utilisation_pct } => { - assert!(utilisation_pct >= 90); - } - other => panic!("expected NotAttempted, got {other:?}"), - } - assert_eq!( - summarizer.call_count(), - 0, - "summarizer must not run when autocompact is disabled" - ); -} - -#[tokio::test] -async fn disabled_manager_returns_noop() { - let summarizer = MockSummarizer::ok(); - let mut config = ContextConfig::default(); - config.enabled = false; - let mut manager = ContextManager::new( - &config, - summarizer.clone(), - "test-model".into(), - SystemPromptBuilder::with_defaults(), - ); - - // High utilisation would normally trigger something. - manager.record_usage(&UsageInfo { - input_tokens: 96_000, - output_tokens: 2_000, - context_window: 100_000, - ..Default::default() - }); - - let mut history = vec![user("a"), user("b"), user("c")]; - let outcome = manager.reduce_before_call(&mut history).await.unwrap(); - assert!(matches!(outcome, ReductionOutcome::NoOp)); - assert_eq!(summarizer.call_count(), 0); +fn default_manager() -> ContextManager { + manager_with_config(&ContextConfig::default()) } #[test] fn stats_reports_snapshot() { - let summarizer = MockSummarizer::ok(); - let mut manager = manager_with(summarizer); + let mut manager = default_manager(); manager.record_usage(&UsageInfo { input_tokens: 10_000, output_tokens: 2_000, @@ -321,103 +40,61 @@ fn stats_reports_snapshot() { assert_eq!(s.session_memory_total_tool_calls, 3); } -struct RecordingModelSummarizer { - model: Mutex>, -} - -#[async_trait] -impl Summarizer for RecordingModelSummarizer { - async fn summarize( - &self, - history: &mut Vec, - model: &str, - ) -> anyhow::Result { - *self.model.lock().unwrap() = Some(model.to_string()); - history.clear(); - Ok(SummaryStats { - messages_removed: 0, - approx_tokens_freed: 0, - summary_chars: 0, - }) - } -} - -#[tokio::test] -async fn new_honors_summarizer_model_override() { - let summarizer = Arc::new(RecordingModelSummarizer { - model: Mutex::new(None), - }); - let mut config = ContextConfig::default(); - config.summarizer_model = Some("compact-model".into()); - let mut manager = ContextManager::new( - &config, - summarizer.clone(), - "main-model".into(), - SystemPromptBuilder::with_defaults(), - ); - manager.record_usage(&UsageInfo { - input_tokens: 92_000, - output_tokens: 4_000, - context_window: 100_000, - ..Default::default() - }); - - let mut history = vec![user("one"), user("two"), user("three")]; - let _ = manager - .reduce_before_call(&mut history) - .await - .expect("reduce"); - - assert_eq!( - summarizer.model.lock().unwrap().as_deref(), - Some("compact-model") - ); -} - #[test] fn new_exposes_tool_budget_and_markdown_preference_from_config() { - let summarizer = MockSummarizer::ok(); let mut config = ContextConfig::default(); config.tool_result_budget_bytes = 4096; config.prefer_markdown_tool_output = true; - let manager = ContextManager::new( - &config, - summarizer, - "main-model".into(), - SystemPromptBuilder::with_defaults(), - ); + let manager = manager_with_config(&config); assert_eq!(manager.tool_result_budget_bytes(), 4096); assert!(manager.prefer_markdown_tool_output()); } +#[test] +fn microcompact_keep_recent_reflects_config_default() { + // The default keep-recent count is exposed so the tinyagents + // MicrocompactMiddleware can honor the same knob. + let manager = default_manager(); + assert_eq!( + manager.microcompact_keep_recent(), + crate::openhuman::context::DEFAULT_KEEP_RECENT_TOOL_RESULTS + ); +} + +#[test] +fn autocompact_enabled_requires_both_master_and_autocompact_flags() { + // Both on → summarization allowed. + let both = manager_with_config(&ContextConfig::default()); + assert!(both.autocompact_enabled()); + + // Master context switch off → summarization off regardless of autocompact. + let mut disabled = ContextConfig::default(); + disabled.enabled = false; + assert!(!manager_with_config(&disabled).autocompact_enabled()); + + // Autocompact specifically off → summarization off. + let mut no_autocompact = ContextConfig::default(); + no_autocompact.autocompact_enabled = false; + assert!(!manager_with_config(&no_autocompact).autocompact_enabled()); +} + #[test] fn super_context_enabled_reflects_config() { // Default config: on. - let on = ContextManager::new( - &ContextConfig::default(), - MockSummarizer::ok(), - "m".into(), - SystemPromptBuilder::with_defaults(), - ); + let on = default_manager(); assert!(on.super_context_enabled()); // Explicitly disabled in config → getter reports off. let mut config = ContextConfig::default(); config.super_context_enabled = false; - let off = ContextManager::new( - &config, - MockSummarizer::ok(), - "m".into(), - SystemPromptBuilder::with_defaults(), - ); + let off = manager_with_config(&config); assert!(!off.super_context_enabled()); } #[test] fn session_memory_lifecycle_changes_should_extract_state() { - let summarizer = MockSummarizer::ok(); - let mut manager = manager_with(summarizer); + let mut manager = default_manager(); manager.record_usage(&UsageInfo { input_tokens: 20_000, output_tokens: 0, @@ -443,8 +120,7 @@ fn session_memory_lifecycle_changes_should_extract_state() { #[test] fn session_memory_handle_mutations_are_reflected_in_manager_stats() { - let summarizer = MockSummarizer::ok(); - let manager = manager_with(summarizer); + let manager = default_manager(); let handle = manager.session_memory_handle(); { let mut state = handle.lock().unwrap(); diff --git a/src/openhuman/context/mod.rs b/src/openhuman/context/mod.rs index 1f41de03b..7b4ef044f 100644 --- a/src/openhuman/context/mod.rs +++ b/src/openhuman/context/mod.rs @@ -34,13 +34,11 @@ pub mod manager; pub mod microcompact; pub mod pipeline; pub mod prompt; -pub mod segment_recap_summarizer; pub mod session_memory; -pub mod summarizer; pub mod tool_result_budget; pub use guard::{ContextCheckResult, ContextGuard}; -pub use manager::{ContextManager, ContextStats, ReductionOutcome}; +pub use manager::{ContextManager, ContextStats}; pub use microcompact::{ microcompact, MicrocompactStats, CLEARED_PLACEHOLDER, DEFAULT_KEEP_RECENT_TOOL_RESULTS, }; @@ -50,15 +48,10 @@ pub use prompt::{ PromptSection, PromptTool, RuntimeSection, SafetySection, SystemPromptBuilder, ToolsSection, WorkspaceSection, }; -pub use segment_recap_summarizer::SegmentRecapSummarizer; pub use session_memory::{ SessionMemoryConfig, SessionMemoryState, ARCHIVIST_EXTRACTION_PROMPT, DEFAULT_MIN_TOKEN_GROWTH, DEFAULT_MIN_TOOL_CALLS, DEFAULT_MIN_TURNS_BETWEEN, }; -pub use summarizer::{ - summarize_chat_history, EngineAutocompact, ProviderSummarizer, Summarizer, SummaryStats, - DEFAULT_KEEP_RECENT, DEFAULT_SUMMARIZER_TEMPERATURE, -}; pub use tool_result_budget::{ apply_tool_result_budget, BudgetOutcome, DEFAULT_TOOL_RESULT_BUDGET_BYTES, }; diff --git a/src/openhuman/context/segment_recap_summarizer.rs b/src/openhuman/context/segment_recap_summarizer.rs deleted file mode 100644 index 08f37b5cb..000000000 --- a/src/openhuman/context/segment_recap_summarizer.rs +++ /dev/null @@ -1,226 +0,0 @@ -//! Phase 1.5 — segment-recap-backed compaction summarizer. -//! -//! [`SegmentRecapSummarizer`] wraps an inner [`Summarizer`] (normally a -//! [`super::ProviderSummarizer`]) and intercepts the autocompaction call to -//! try the rolling segment recap from the [`ArchivistHook`] first. -//! -//! ## Strategy -//! -//! When `AutocompactionRequested` fires, the manager calls -//! [`SegmentRecapSummarizer::summarize`]. That method: -//! -//! 1. Calls [`ArchivistHook::rolling_segment_recap`] for the current session. -//! 2. If a non-empty recap is returned, it replaces the evicted head of the -//! history with a single `[segment-recap]` system message containing that -//! text. The head/tail split follows the same "never break a tool-call -//! pair" rule as the inner summarizer. -//! 3. If the recap is `None`/empty (archivist absent, no open segment, LLM -//! fail, flag off) — soft-fallback: delegate to the inner summarizer -//! unchanged. The prompt is never left over-budget; the existing -//! compaction safety net always fires. -//! -//! ## Invariants (never violated by this code) -//! -//! - The `ArchivistHook` is only *read* through `rolling_segment_recap` — no -//! segment is closed, no `segment_set_summary` is written, no embedding is -//! produced. Those side-effects are finalize-only (Phase 1 owns them). -//! - Events/profile/tree derive from RAW episodic rows — this path never -//! touches those subsystems. -//! - On any error in the recap path, the inner summarizer runs. The -//! circuit-breaker logic in [`super::ContextManager`] is fed the result of -//! whichever path actually ran; the breaker sees a success if either path -//! succeeds. -//! - The history is either fully rewritten (success) or left completely -//! untouched (the inner summarizer also fails, in which case its `Err` is -//! returned — the breaker will nudge and eventually trip, preventing -//! infinite loops). - -use super::summarizer::{Summarizer, SummaryStats}; -use crate::openhuman::agent::harness::archivist::ArchivistHook; -use crate::openhuman::inference::provider::{ChatMessage, ConversationMessage}; -use anyhow::Result; -use async_trait::async_trait; -use std::sync::Arc; - -/// How many messages at the tail of the history are preserved verbatim when -/// the recap path fires (same default as [`super::ProviderSummarizer`] so the -/// two paths preserve the same recent context window). -const DEFAULT_KEEP_RECENT: usize = super::summarizer::DEFAULT_KEEP_RECENT; - -/// Compaction summarizer that prefers the rolling segment recap from the -/// archivist over a standalone LLM summarization call. -/// -/// Constructed by [`super::SessionBuilder`] when -/// `config.learning.unified_compaction_enabled` is `true` and an -/// `ArchivistHook` is wired in; otherwise the plain `ProviderSummarizer` -/// is used and this type is never instantiated. -pub struct SegmentRecapSummarizer { - /// The archivist that owns episodic state and can produce rolling recaps. - archivist: Arc, - /// Session ID needed to look up the open segment. - session_id: String, - /// Inner summarizer — the safety-net fallback (normally a - /// [`super::ProviderSummarizer`] wrapping the same provider the agent uses - /// for its normal turns). - inner: Arc, - /// How many tail messages to preserve verbatim when the recap path fires. - keep_recent: usize, -} - -impl SegmentRecapSummarizer { - /// Construct a new [`SegmentRecapSummarizer`]. - /// - /// * `archivist` — shared archivist handle (same Arc the agent holds). - /// * `session_id` — the session whose open segment will be recapped. - /// * `inner` — fallback summarizer; used when the recap is unavailable. - pub fn new( - archivist: Arc, - session_id: String, - inner: Arc, - ) -> Self { - Self { - archivist, - session_id, - inner, - keep_recent: DEFAULT_KEEP_RECENT, - } - } - - /// Override how many tail messages are preserved verbatim. Useful in - /// tests that want to exercise the recap path with short histories. - #[cfg(test)] - pub fn with_keep_recent(mut self, n: usize) -> Self { - self.keep_recent = n; - self - } -} - -#[async_trait] -impl Summarizer for SegmentRecapSummarizer { - async fn summarize( - &self, - history: &mut Vec, - model: &str, - ) -> Result { - // ── 1. Try the rolling segment recap ───────────────────────────── - let recap_opt = self.archivist.rolling_segment_recap(&self.session_id).await; - - match recap_opt { - Some(recap) if !recap.is_empty() => { - tracing::info!( - session_id = %self.session_id, - recap_chars = recap.len(), - history_len = history.len(), - keep_recent = self.keep_recent, - "[context::segment_recap] using rolling segment recap as compaction text" - ); - - let total = history.len(); - if total <= self.keep_recent { - tracing::debug!( - session_id = %self.session_id, - "[context::segment_recap] history below keep_recent — \ - nothing to compact (NoOp)" - ); - return Ok(SummaryStats::default()); - } - - // Use the same "never break a tool-call pair" split rule as - // ProviderSummarizer so the API invariant - // (AssistantToolCalls ↔ ToolResults) is preserved. - // Delegates to the canonical implementation in `summarizer` - // so the two compaction paths share a single definition. - let proposed_head = total - self.keep_recent; - let head_len = super::summarizer::snap_split_forward(history, proposed_head); - if head_len == 0 { - tracing::debug!( - session_id = %self.session_id, - "[context::segment_recap] split snapped to 0 — \ - falling back to inner summarizer" - ); - return self.inner.summarize(history, model).await; - } - - // Estimate bytes freed (same formula as ProviderSummarizer). - let approx_input_bytes: usize = history[..head_len] - .iter() - .map(|m| conversation_message_approx_bytes(m)) - .sum(); - - let summary_body = format!( - "[segment-recap] Summary of {head_len} earlier messages \ - (archivist rolling recap):\n\n{recap}" - ); - let summary_chars = summary_body.len(); - let approx_tokens_freed = (approx_input_bytes as u64) - .saturating_sub(summary_chars as u64) - .div_ceil(4); - - // Atomically rewrite the head in place — no partial mutation on - // failure because all failure paths returned early above. - let tail: Vec = history.drain(head_len..).collect(); - history.clear(); - history.push(ConversationMessage::Chat(ChatMessage::system(summary_body))); - history.extend(tail); - - tracing::info!( - session_id = %self.session_id, - messages_removed = head_len, - approx_tokens_freed, - summary_chars, - "[context::segment_recap] compaction via segment recap complete" - ); - - Ok(SummaryStats { - messages_removed: head_len, - approx_tokens_freed, - summary_chars, - }) - } - - // ── 2. Soft-fallback ───────────────────────────────────────── - // - // The rolling recap was unavailable (None, empty, LLM fail, no - // open segment, archivist disabled). Delegate to the inner - // summarizer so the prompt is NEVER left over-budget. This is the - // "always bounded" guarantee. - recap_result => { - let reason = match &recap_result { - None => "rolling_segment_recap returned None", - Some(s) if s.is_empty() => "rolling_segment_recap returned empty string", - _ => "unreachable", - }; - tracing::info!( - session_id = %self.session_id, - reason, - "[context::segment_recap] recap unavailable — \ - falling back to inner summarizer" - ); - self.inner.summarize(history, model).await - } - } - } -} - -/// Very rough byte count for a [`ConversationMessage`] — used only for the -/// "approx_tokens_freed" stat. Accuracy doesn't matter much (it's the same -/// rough accounting ProviderSummarizer uses). -fn conversation_message_approx_bytes(msg: &ConversationMessage) -> usize { - match msg { - ConversationMessage::Chat(m) => m.content.len(), - ConversationMessage::AssistantToolCalls { - text, tool_calls, .. - } => { - text.as_deref().map_or(0, str::len) - + tool_calls - .iter() - .map(|tc| tc.arguments.len()) - .sum::() - } - ConversationMessage::ToolResults(results) => results.iter().map(|r| r.content.len()).sum(), - } -} - -#[cfg(test)] -#[path = "segment_recap_summarizer_tests.rs"] -mod tests; diff --git a/src/openhuman/context/segment_recap_summarizer_tests.rs b/src/openhuman/context/segment_recap_summarizer_tests.rs deleted file mode 100644 index 0f1e212f6..000000000 --- a/src/openhuman/context/segment_recap_summarizer_tests.rs +++ /dev/null @@ -1,572 +0,0 @@ -//! Tests for Phase 1.5 — `SegmentRecapSummarizer`. -//! -//! Proves: -//! 1. Rolling recap summarizes the open segment WITHOUT closing it, writing -//! `segment_set_summary`, or producing an embedding row. -//! 2. When a rolling recap is available, the compaction replacement text -//! equals it (provenance/path), not a separately-generated summary. -//! 3. Soft-fallback: archivist absent / LLM stub failing / flag off → -//! compaction falls back to the inner summarizer; prompt stays bounded. -//! 4. Finalize path (Phase 1 `on_segment_closed`) still works unchanged -//! (recap persisted + embedded at close) — regression guard. - -use super::*; -use crate::openhuman::agent::harness::archivist::ArchivistHook; -use crate::openhuman::agent::hooks::{PostTurnHook as _, TurnContext}; -use crate::openhuman::context::summarizer::{Summarizer, SummaryStats}; -use crate::openhuman::inference::provider::{ChatMessage, ConversationMessage}; -use crate::openhuman::memory::chat::ChatPrompt; -use crate::openhuman::memory_store::{fts5, segments as seg}; -use anyhow::Result; -use async_trait::async_trait; -use parking_lot::Mutex; -use rusqlite::Connection; -use std::sync::Arc; - -// ── Shared test infrastructure ──────────────────────────────────────────────── - -fn setup_conn() -> Arc> { - let conn = Connection::open_in_memory().unwrap(); - conn.execute_batch(fts5::EPISODIC_INIT_SQL).unwrap(); - conn.execute_batch(seg::SEGMENTS_INIT_SQL).unwrap(); - conn.execute_batch(crate::openhuman::memory_store::events::EVENTS_INIT_SQL) - .unwrap(); - conn.execute_batch(crate::openhuman::memory_store::profile::PROFILE_INIT_SQL) - .unwrap(); - Arc::new(Mutex::new(conn)) -} - -/// Stub ChatProvider always returns a fixed recap string. -struct StubChatProvider; - -#[async_trait] -impl crate::openhuman::memory::chat::ChatProvider for StubChatProvider { - fn name(&self) -> &str { - "stub:test" - } - async fn chat_for_json(&self, _prompt: &ChatPrompt) -> Result { - Ok("rolling recap: discussed memory safety in Rust".to_string()) - } - async fn chat_for_text(&self, _prompt: &ChatPrompt) -> Result { - Ok("rolling recap: discussed memory safety in Rust".to_string()) - } -} - -/// Stub ChatProvider that always fails — simulates LLM unavailability. -struct FailingChatProvider; - -#[async_trait] -impl crate::openhuman::memory::chat::ChatProvider for FailingChatProvider { - fn name(&self) -> &str { - "stub:failing" - } - async fn chat_for_json(&self, _prompt: &ChatPrompt) -> Result { - anyhow::bail!("stub LLM unavailable") - } - async fn chat_for_text(&self, _prompt: &ChatPrompt) -> Result { - anyhow::bail!("stub LLM unavailable") - } -} - -/// Stub Embedder returns a fixed unit vector. -struct StubEmbedder; - -#[async_trait] -impl crate::openhuman::memory_tree::score::embed::Embedder for StubEmbedder { - fn name(&self) -> &'static str { - "stub-embedder-v1" - } - async fn embed(&self, _text: &str) -> Result> { - Ok(vec![0.5_f32, 0.5, 0.5, 0.5]) - } -} - -/// Inner mock summarizer that records call count and always succeeds. -struct RecordingSummarizer { - calls: std::sync::Mutex, -} - -impl RecordingSummarizer { - fn new() -> Arc { - Arc::new(Self { - calls: std::sync::Mutex::new(0), - }) - } - fn call_count(&self) -> usize { - *self.calls.lock().unwrap() - } -} - -#[async_trait] -impl Summarizer for RecordingSummarizer { - async fn summarize( - &self, - history: &mut Vec, - _model: &str, - ) -> Result { - *self.calls.lock().unwrap() += 1; - // Replace history with a single "inner fallback" message. - let removed = history.len(); - history.clear(); - history.push(ConversationMessage::Chat(ChatMessage::system( - "inner fallback summary", - ))); - Ok(SummaryStats { - messages_removed: removed, - approx_tokens_freed: 500, - summary_chars: 22, - }) - } -} - -fn user(s: &str) -> ConversationMessage { - ConversationMessage::Chat(ChatMessage::user(s)) -} - -// ── Test 1: rolling recap does NOT close the segment or write summary/embedding - -/// `rolling_segment_recap` must produce a non-empty string from the open -/// segment's entries without closing it, writing `segment_set_summary`, or -/// producing an embedding row. -#[tokio::test] -async fn rolling_recap_does_not_close_segment_or_write_summary_or_embedding() { - let conn = setup_conn(); - let hook = Arc::new(ArchivistHook::new_with_stubs( - conn.clone(), - Arc::new(StubChatProvider), - Arc::new(StubEmbedder), - )); - - let session = "p15-rolling-no-close"; - - // Write two turns into the open segment (no boundary fires). - for i in 1..=2u64 { - hook.on_turn_complete(&TurnContext { - user_message: format!("Turn {i} about Rust memory safety"), - assistant_response: format!("Answer {i} about ownership"), - tool_calls: vec![], - turn_duration_ms: 50, - session_id: Some(session.into()), - agent_id: None, - entrypoint: None, - iteration_count: i as usize, - }) - .await - .unwrap(); - } - - // Verify the segment is still open before calling rolling_segment_recap. - let open_before = seg::open_segment_for_session(&conn, session).unwrap(); - assert!( - open_before.is_some(), - "Expected an open segment after 2 turns (no boundary should have fired)" - ); - - // Call rolling_segment_recap — must NOT close the segment or write DB state. - let recap = hook.rolling_segment_recap(session).await; - assert!( - recap.is_some(), - "Expected rolling_segment_recap to return Some for an open segment with entries" - ); - let recap_text = recap.unwrap(); - assert!( - !recap_text.is_empty(), - "Expected non-empty recap from rolling_segment_recap" - ); - - // Segment must STILL be open after the call. - let open_after = seg::open_segment_for_session(&conn, session).unwrap(); - assert!( - open_after.is_some(), - "Segment must still be open after rolling_segment_recap (no close side-effect)" - ); - - let seg_id = open_after.unwrap().segment_id; - - // No summary must have been written. - let all_segs = seg::segments_by_namespace(&conn, "global", 10).unwrap(); - let our_seg = all_segs.iter().find(|s| s.segment_id == seg_id).unwrap(); - assert!( - our_seg.summary.is_none() || our_seg.summary.as_deref() == Some(""), - "segment_set_summary must NOT have been called by rolling_segment_recap; \ - found: {:?}", - our_seg.summary - ); - - // No embedding must have been written. - let embedding = seg::segment_embedding_get(&conn, &seg_id, "stub-embedder-v1").unwrap(); - assert!( - embedding.is_none(), - "No embedding must be written by rolling_segment_recap \ - (finalize-only invariant); found an embedding for segment={seg_id}" - ); -} - -// ── Test 2: compaction uses recap text, not inner summarizer ───────────────── - -/// When the rolling recap is available, `SegmentRecapSummarizer::summarize` -/// must use it as the replacement text — the inner summarizer must NOT be -/// called and the history head must contain `[segment-recap]`. -#[tokio::test] -async fn compaction_uses_recap_text_not_inner_summarizer() { - let conn = setup_conn(); - let hook = Arc::new(ArchivistHook::new_with_stubs( - conn.clone(), - Arc::new(StubChatProvider), - Arc::new(StubEmbedder), - )); - - let session = "p15-compaction-recap"; - - // Write one turn so the open segment has entries. - hook.on_turn_complete(&TurnContext { - user_message: "Tell me about Rust ownership".into(), - assistant_response: "Ownership prevents data races.".into(), - tool_calls: vec![], - turn_duration_ms: 50, - session_id: Some(session.into()), - agent_id: None, - entrypoint: None, - iteration_count: 1, - }) - .await - .unwrap(); - - let inner = RecordingSummarizer::new(); - let recap_summ = SegmentRecapSummarizer::new( - Arc::clone(&hook), - session.to_string(), - inner.clone() as Arc, - ) - .with_keep_recent(1); // compact everything except the last message - - // Build a history that exceeds keep_recent so summarization fires. - let mut history = vec![ - user("message 1 (will be evicted)"), - user("message 2 (will be evicted)"), - user("message 3 — preserved tail"), - ]; - - let stats = recap_summ - .summarize(&mut history, "test-model") - .await - .expect("summarize must succeed"); - - // Inner summarizer must NOT have been called. - assert_eq!( - inner.call_count(), - 0, - "Inner summarizer must not be called when rolling recap is available" - ); - - // Stats must reflect a non-zero reduction. - assert!( - stats.messages_removed > 0, - "Expected some messages to be removed; got 0" - ); - - // The new head message must contain the recap text (not the inner fallback). - assert_eq!( - history.len(), - 2, - "Expected summary message + 1 preserved tail; got {}", - history.len() - ); - match &history[0] { - ConversationMessage::Chat(m) => { - assert!( - m.content.contains("[segment-recap]"), - "Expected summary message to contain '[segment-recap]'; got: {:?}", - m.content - ); - // Must also contain the actual recap text from the stub. - assert!( - m.content.contains("rolling recap"), - "Expected summary to contain recap text from stub provider; got: {:?}", - m.content - ); - } - other => panic!("Expected Chat message for summary, got: {:?}", other), - } - - // Tail must be preserved verbatim. - match &history[1] { - ConversationMessage::Chat(m) => { - assert_eq!(m.content, "message 3 — preserved tail"); - } - other => panic!("Expected Chat tail message, got: {:?}", other), - } -} - -// ── Test 3: soft-fallback when archivist absent ─────────────────────────────── - -/// When the archivist is disabled (no conn), `rolling_segment_recap` returns -/// `None` and `SegmentRecapSummarizer` must fall back to the inner summarizer. -/// The prompt must remain bounded (no panic, no over-budget, no error). -#[tokio::test] -async fn soft_fallback_when_archivist_absent() { - // Use a disabled archivist (no SQLite connection). - let disabled_hook = Arc::new(ArchivistHook::disabled()); - - let inner = RecordingSummarizer::new(); - let recap_summ = SegmentRecapSummarizer::new( - disabled_hook, - "no-session".to_string(), - inner.clone() as Arc, - ) - .with_keep_recent(1); - - let mut history = vec![user("evict me"), user("evict me too"), user("keep me")]; - - let stats = recap_summ - .summarize(&mut history, "test-model") - .await - .expect("summarize must not return Err — soft-fallback guarantees boundedness"); - - // Inner summarizer must have been called exactly once. - assert_eq!( - inner.call_count(), - 1, - "Inner summarizer must be called when archivist is absent" - ); - - // History must be bounded (inner replaced it). - assert_eq!( - history.len(), - 1, - "Inner summarizer must have reduced the history" - ); - match &history[0] { - ConversationMessage::Chat(m) => { - assert_eq!(m.content, "inner fallback summary"); - } - _ => panic!("Expected system summary from inner summarizer"), - } - - // Stats must be valid (not zeroes — inner mock returns non-zero values). - assert_eq!(stats.approx_tokens_freed, 500); -} - -// ── Test 4: soft-fallback when LLM stub fails ──────────────────────────────── - -/// Tier 3 — the bookend heuristic stub must NEVER become live compaction -/// text. Here there is no chat provider configured, so `summarize_entries` -/// produces only `segments::fallback_summary` (bookend stub) with -/// `produced_by_llm = false`. `rolling_segment_recap` must therefore return -/// `None`, and `SegmentRecapSummarizer` falls back to the inner summarizer. -/// (Option A: only a genuine summariser-produced recap may be compaction.) -#[tokio::test] -async fn bookend_stub_never_becomes_compaction_falls_back_to_inner() { - let conn = setup_conn(); - // `ArchivistHook::new` leaves `chat_provider = None` → summarize_entries - // takes the no-provider branch → bookend stub, produced_by_llm = false. - let hook = Arc::new(ArchivistHook::new(conn.clone(), true)); - - let session = "p15-bookend-stub-none"; - - hook.on_turn_complete(&TurnContext { - user_message: "Hello, what is Rust?".into(), - assistant_response: "Rust is a systems language.".into(), - tool_calls: vec![], - turn_duration_ms: 50, - session_id: Some(session.into()), - agent_id: None, - entrypoint: None, - iteration_count: 1, - }) - .await - .unwrap(); - - // Only the bookend stub is available → MUST be None. - let recap = hook.rolling_segment_recap(session).await; - assert!( - recap.is_none(), - "rolling_segment_recap must return None when only the bookend stub \ - is available — the stub must never be live compaction text" - ); - - let inner = RecordingSummarizer::new(); - let recap_summ = SegmentRecapSummarizer::new( - Arc::clone(&hook), - session.to_string(), - inner.clone() as Arc, - ) - .with_keep_recent(1); - - let mut history = vec![user("evict"), user("evict2"), user("keep")]; - recap_summ - .summarize(&mut history, "test-model") - .await - .expect("must not panic — inner summarizer is the safety net"); - - assert_eq!( - inner.call_count(), - 1, - "Inner summarizer must run when only the bookend stub is available \ - (stub must never be live compaction text)" - ); -} - -/// Tier 2 — when a chat provider IS configured but its call fails, -/// `LlmSummariser`'s soft-fallback yields an *inert clipped-content* recap -/// (the real conversation text, truncated — NOT the bookend stub). Per -/// Option A this is acceptable as live compaction text (real content, -/// strictly better than no compaction), so `rolling_segment_recap` returns -/// `Some` and the inner summarizer is NOT used. -#[tokio::test] -async fn failing_provider_yields_inert_clipped_recap_used_as_compaction() { - let conn = setup_conn(); - let hook = Arc::new(ArchivistHook::new_with_stubs( - conn.clone(), - Arc::new(FailingChatProvider), - Arc::new(StubEmbedder), - )); - - let session = "p15-inert-clipped-kept"; - - hook.on_turn_complete(&TurnContext { - user_message: "Explain ownership in Rust in detail.".into(), - assistant_response: "Ownership means each value has a single owner; \ - borrows are checked at compile time." - .into(), - tool_calls: vec![], - turn_duration_ms: 50, - session_id: Some(session.into()), - agent_id: None, - entrypoint: None, - iteration_count: 1, - }) - .await - .unwrap(); - - // Provider present but failing → summarize_entries returns a non-LLM - // fallback, which rolling_segment_recap must treat as unavailable for - // live compaction. - let recap = hook.rolling_segment_recap(session).await; - assert!( - recap.is_none(), - "Non-LLM fallback recap text must not be used as live compaction input" - ); - - let inner = RecordingSummarizer::new(); - let recap_summ = SegmentRecapSummarizer::new( - Arc::clone(&hook), - session.to_string(), - inner.clone() as Arc, - ) - .with_keep_recent(1); - - let mut history = vec![user("evict"), user("evict2"), user("keep")]; - recap_summ - .summarize(&mut history, "test-model") - .await - .expect("must not panic"); - - assert_eq!( - inner.call_count(), - 1, - "Inner summarizer must run when rolling recap is unavailable after \ - provider failure" - ); -} - -// ── Test 5: flag off → inner summarizer runs, Phase 1.5 absent ─────────────── - -/// When `unified_compaction_enabled = false`, the `SegmentRecapSummarizer` -/// is NOT instantiated — the builder uses `ProviderSummarizer` directly. -/// This test verifies the flag-off path via the `RecordingSummarizer` fallback: -/// a `SegmentRecapSummarizer` with an otherwise-healthy archivist must still -/// fall through to the inner summarizer if the session has no entries yet -/// (no open segment entries → recap = None → inner). -#[tokio::test] -async fn no_entries_returns_none_and_inner_summarizer_fires() { - let conn = setup_conn(); - // Archivist with no turns written — no open segment, no entries. - let hook = Arc::new(ArchivistHook::new_with_stubs( - conn.clone(), - Arc::new(StubChatProvider), - Arc::new(StubEmbedder), - )); - - let inner = RecordingSummarizer::new(); - let recap_summ = SegmentRecapSummarizer::new( - Arc::clone(&hook), - "empty-session".to_string(), - inner.clone() as Arc, - ) - .with_keep_recent(1); - - let mut history = vec![user("a"), user("b"), user("c")]; - let _stats = recap_summ - .summarize(&mut history, "test-model") - .await - .expect("must not error"); - - // With no open segment, rolling_segment_recap returns None → - // inner summarizer fires. - assert_eq!( - inner.call_count(), - 1, - "Inner summarizer must run when session has no open segment entries" - ); -} - -// ── Test 6: Phase 1 regression guard ───────────────────────────────────────── - -/// `on_segment_closed` (finalize path) still works after Phase 1.5 refactor: -/// the shared `summarize_entries` helper must produce the same result, and -/// `segment_set_summary` + embedding must still fire at close time. -#[tokio::test] -async fn phase1_finalize_path_still_persists_summary_and_embedding() { - let conn = setup_conn(); - let hook = Arc::new(ArchivistHook::new_with_stubs( - conn.clone(), - Arc::new(StubChatProvider), - Arc::new(StubEmbedder), - )); - - let session = "p15-finalize-regression"; - - // Two turns in the first segment. - for i in 1..=2u64 { - hook.on_turn_complete(&TurnContext { - user_message: format!("Rust turn {i}"), - assistant_response: format!("Answer {i}"), - tool_calls: vec![], - turn_duration_ms: 50, - session_id: Some(session.into()), - agent_id: None, - entrypoint: None, - iteration_count: i as usize, - }) - .await - .unwrap(); - } - - // Force-flush to trigger on_segment_closed (finalize path). - hook.flush_open_segment(session).await; - - // The closed segment must have a summary. - let all_segs = seg::segments_by_namespace(&conn, "global", 10).unwrap(); - let flushed = all_segs - .iter() - .find(|s| { - s.session_id == session && s.summary.as_ref().map(|s| !s.is_empty()).unwrap_or(false) - }) - .expect("Expected flushed segment to have a non-empty summary after Phase 1.5 refactor"); - - let summary = flushed.summary.as_ref().unwrap(); - // The stub always returns "rolling recap: discussed memory safety in Rust". - assert!( - summary.contains("rolling recap"), - "Expected summary to contain stub recap text after finalize; got: {summary:?}" - ); - - // Embedding must also still be written (finalize-only invariant). - let embedding = - seg::segment_embedding_get(&conn, &flushed.segment_id, "stub-embedder-v1").unwrap(); - assert!( - embedding.is_some(), - "Expected finalize-time embedding to still be written after Phase 1.5 refactor" - ); -} diff --git a/src/openhuman/context/summarizer.rs b/src/openhuman/context/summarizer.rs deleted file mode 100644 index 286ea2d6b..000000000 --- a/src/openhuman/context/summarizer.rs +++ /dev/null @@ -1,593 +0,0 @@ -//! LLM-backed conversation summarization. -//! -//! The context [`super::ContextPipeline`] is deliberately pure — when -//! it decides the agent history is over budget and can't be rescued by -//! cheap stages (microcompact, tool-result budget), it returns -//! [`super::PipelineOutcome::AutocompactionRequested`] and trusts the -//! caller to dispatch an LLM summarization. -//! -//! This module owns that dispatch. [`Summarizer`] is the async trait -//! [`super::ContextManager`] calls on behalf of agents; the default -//! implementation [`ProviderSummarizer`] wraps an `Arc` -//! and executes a single chat completion against the same provider the -//! agent uses for its normal turns. Tests pass a mock implementation -//! so `ContextManager::reduce_before_call` can be exercised without -//! touching the network. -//! -//! ## Reduction strategy -//! -//! The summarizer keeps the `keep_recent` most-recent messages -//! untouched (so the model still has fresh context for its next turn), -//! replays the older head of the conversation as a plain-text -//! transcript, asks the LLM to compress it into a dense note, and -//! replaces the head with a single `system` [`ConversationMessage`] -//! holding that note. The API invariant -//! (`AssistantToolCalls` ↔ `ToolResults`) is preserved because we -//! never split a pair across the head/tail boundary — if the -//! boundary lands mid-pair we push it forward until it sits between -//! complete turns. - -use super::microcompact::MicrocompactStats; -use crate::openhuman::inference::provider::{ChatMessage, ConversationMessage, Provider}; -use anyhow::Result; -use async_trait::async_trait; -use std::borrow::Cow; -use std::fmt::Write as _; -use std::sync::Arc; - -/// Default number of most-recent messages preserved verbatim by the -/// summarizer. Anything older gets collapsed into the summary note. -pub const DEFAULT_KEEP_RECENT: usize = 10; - -/// Default temperature for summarization calls. Low-ish so the same -/// history produces stable summaries across retries. -pub const DEFAULT_SUMMARIZER_TEMPERATURE: f64 = 0.2; - -/// The system prompt pinned to every summarization call. -/// -/// Structured, reference-only checkpoint template adapted from the Hermes -/// agent's context compactor (`agent/context_compressor.py`): it asks for a -/// fixed set of sections so the handoff is dense and parseable, and it frames -/// the whole note as **background reference** so a weaker model doesn't read a -/// summarized "pending ask" as a fresh instruction. Kept as tight as the -/// structure allows — this call's whole purpose is to *free* tokens. -pub const SUMMARIZER_SYSTEM_PROMPT: &str = "You are a summarization agent creating a context \ -checkpoint for an AI assistant whose conversation has grown too long to fit its context window. \ -You are given the earlier portion of a chronological conversation (user, assistant, and tool \ -messages). Compress it into a dense, structured handoff note that the assistant will read as \ -BACKGROUND REFERENCE — not as new instructions.\n\ -\n\ -Rules:\n\ -- Write ONLY the structured summary below. No greeting, no preamble, no closing remarks.\n\ -- This is reference material describing turns that ALREADY happened. Do NOT answer any question \ -or perform any task mentioned in it. The assistant acts only on the live messages that appear \ -AFTER this summary; if a later message contradicts or changes topic, the later message wins.\n\ -- Redact secrets: replace any API keys, tokens, passwords, or credentials with [REDACTED] (note \ -that a credential was present).\n\ -- Be specific and information-dense: prefer concrete facts (paths, names, values, decisions) over \ -narration. Drop greetings, small talk, and redundant acknowledgements.\n\ -\n\ -Produce exactly these sections (write \"None\" when a section is empty):\n\ -\n\ -## Goal\n\ -What the user is ultimately trying to accomplish.\n\ -\n\ -## Completed Actions\n\ -Numbered list of what has already been done, with key results/outputs.\n\ -\n\ -## Active State\n\ -The current state of the work right now: files touched, systems configured, what is true.\n\ -\n\ -## Key Decisions\n\ -Decisions made and the reasoning, so they are not relitigated.\n\ -\n\ -## Resolved Questions\n\ -Questions already answered — include the answer so it is not repeated.\n\ -\n\ -## Pending / Open (reference only)\n\ -Requests or work outstanding in the compacted turns. These are STALE — do NOT act on them unless \ -the latest live message explicitly asks.\n\ -\n\ -## Relevant Files\n\ -Files read, created, or modified, with a one-line note on each.\n\ -\n\ -## Critical Context\n\ -Anything else essential to continue correctly (constraints, environment facts, gotchas)."; - -/// Prefix prepended to the inserted summary message so the model treats the -/// block as a background handoff rather than live instructions. -const SUMMARY_PREFIX: &str = "[CONTEXT COMPACTION — REFERENCE ONLY] Earlier turns were compacted \ -into the summary below. Treat it as background reference, not active instructions — act only on \ -the messages that appear after it."; - -/// Appended to the inserted summary message so the model has an unambiguous -/// boundary. Without it, weaker models read a verbatim quote inside the summary -/// (e.g. an `## Active State` line) as fresh user input. Mirrors the Hermes -/// `_SUMMARY_END_MARKER`. -const SUMMARY_END_MARKER: &str = - "--- END OF CONTEXT SUMMARY — respond to the messages below, not the summary above ---"; - -/// Build the two-message request (`system` instruction + `user` transcript) -/// sent to the summarizer. Shared by the typed [`ProviderSummarizer`] and the -/// `ChatMessage`-level [`summarize_chat_history`] so both paths use the same -/// structured prompt. -fn build_summary_request(transcript: &str) -> Vec { - vec![ - ChatMessage::system(SUMMARIZER_SYSTEM_PROMPT), - ChatMessage::user(format!( - "Summarize the conversation history below for continuation, following the section \ - structure exactly.\n\n--- BEGIN HISTORY ---\n{transcript}\n--- END HISTORY ---" - )), - ] -} - -/// Wrap the model's raw summary in the reference-only prefix + end marker that -/// frame the inserted message. Shared by both summarizer paths. -fn build_summary_message_body(summary: &str) -> String { - format!("{SUMMARY_PREFIX}\n\n{summary}\n\n{SUMMARY_END_MARKER}") -} - -/// Outcome of a single summarization pass. -/// -/// Returned by [`Summarizer::summarize`] so callers — chiefly -/// [`super::ContextManager`] — can log, telemeter, and feed the result -/// back into the compaction circuit breaker on the [`super::ContextGuard`]. -#[derive(Debug, Clone, Default)] -pub struct SummaryStats { - /// How many entries were removed from the head of the history and - /// replaced with the summary message. - pub messages_removed: usize, - /// Character-heuristic estimate of freed tokens (input transcript - /// bytes minus summary bytes, divided by 4). Rough but stable and - /// free. - pub approx_tokens_freed: u64, - /// Total character length of the summary message that replaced the - /// head. Useful for detecting degenerate "summarizer kept every - /// word" responses. - pub summary_chars: usize, -} - -impl SummaryStats { - /// Helper to turn a [`MicrocompactStats`] into a [`SummaryStats`] - /// shaped value when reporting the union through - /// [`super::ReductionOutcome`]. Currently unused but included so - /// the types compose cleanly if a caller ever wants a uniform - /// stats payload. - #[doc(hidden)] - pub fn from_microcompact(stats: &MicrocompactStats) -> Self { - Self { - messages_removed: stats.entries_cleared, - approx_tokens_freed: (stats.bytes_freed as u64).div_ceil(4), - summary_chars: 0, - } - } -} - -/// Trait for anything that can summarize an agent conversation history -/// in place. -/// -/// Implementations must not partially mutate `history` on failure — -/// either the full rewrite succeeds and the function returns `Ok`, or -/// `history` is untouched and the error bubbles up. This contract -/// lets [`super::ContextManager`] treat failures as "nothing happened" -/// when it records the result on its compaction circuit breaker. -#[async_trait] -pub trait Summarizer: Send + Sync { - async fn summarize( - &self, - history: &mut Vec, - model: &str, - ) -> Result; -} - -/// Default summarizer that wraps an `Arc`. -/// -/// Instantiated once per [`super::ContextManager`] — usually by the -/// agent harness at session start — so every summarization inside a -/// session hits the same provider/model. A cheaper `summarizer_model` -/// can be threaded through the caller's -/// [`crate::openhuman::config::ContextConfig`] if summarization on -/// the main model gets expensive; [`super::ContextManager::new`] is -/// responsible for choosing which model string to pass in. -pub struct ProviderSummarizer { - provider: Arc, - keep_recent: usize, - temperature: f64, -} - -impl ProviderSummarizer { - /// Construct a summarizer around `provider` with default tunables. - pub fn new(provider: Arc) -> Self { - Self { - provider, - keep_recent: DEFAULT_KEEP_RECENT, - temperature: DEFAULT_SUMMARIZER_TEMPERATURE, - } - } - - /// Override how many messages are preserved verbatim at the tail. - pub fn with_keep_recent(mut self, n: usize) -> Self { - self.keep_recent = n; - self - } - - /// Override the temperature used for the summarization chat call. - pub fn with_temperature(mut self, t: f64) -> Self { - self.temperature = t; - self - } -} - -#[async_trait] -impl Summarizer for ProviderSummarizer { - async fn summarize( - &self, - history: &mut Vec, - model: &str, - ) -> Result { - let total = history.len(); - if total <= self.keep_recent { - tracing::debug!( - total, - keep_recent = self.keep_recent, - "[context::summarizer] nothing to summarize — history below keep_recent" - ); - return Ok(SummaryStats::default()); - } - - // Head = everything before the preserved tail. Snap the split - // forward so we never break an AssistantToolCalls ↔ ToolResults - // pair. If an `AssistantToolCalls` sits at the proposed split - // point, walk forward until we're past its matching - // `ToolResults` envelope (or until the tail would collapse to - // zero, in which case there's nothing to summarize). - let head_len = snap_split_forward(history, total - self.keep_recent); - if head_len == 0 { - return Ok(SummaryStats::default()); - } - - // Build the plain-text transcript the summarizer reads. - let transcript = render_transcript(&history[..head_len]); - let approx_input_bytes = transcript.len(); - - // Summarization chat call — one turn, no tools, shared structured prompt. - let messages = build_summary_request(&transcript); - - tracing::info!( - model, - head_messages = head_len, - tail_preserved = total - head_len, - approx_input_bytes, - "[context::summarizer] dispatching autocompaction summary" - ); - - let response = self - .provider - .chat_with_history(&messages, model, self.temperature) - .await - .map_err(|e| { - tracing::warn!(error = %e, "[context::summarizer] provider call failed"); - e - })?; - - let summary = response.trim(); - if summary.is_empty() { - anyhow::bail!("summarizer returned empty response"); - } - - let summary_body = build_summary_message_body(summary); - let summary_chars = summary_body.len(); - let approx_tokens_freed = (approx_input_bytes as u64) - .saturating_sub(summary_chars as u64) - .div_ceil(4); - - // Replace the head in place. Drain the tail, clear the vec, - // push the summary, and put the tail back. No partial mutation - // on error paths — everything above returned early. - let tail: Vec = history.drain(head_len..).collect(); - history.clear(); - history.push(ConversationMessage::Chat(ChatMessage::system(summary_body))); - history.extend(tail); - - tracing::info!( - messages_removed = head_len, - approx_tokens_freed, - summary_chars, - "[context::summarizer] autocompaction complete" - ); - - Ok(SummaryStats { - messages_removed: head_len, - approx_tokens_freed, - summary_chars, - }) - } -} - -/// Snap the proposed split point forward until it sits on a clean -/// turn boundary (i.e. not mid-way through an -/// `AssistantToolCalls` → `ToolResults` pair). Returns the adjusted -/// head length. Returns 0 when the adjustment would consume the entire -/// history, meaning there is nothing we can safely summarize without -/// breaking the API invariant. -/// -/// Exported as `pub(super)` so sibling modules (e.g. -/// `segment_recap_summarizer`) can reuse the same invariant instead of -/// maintaining a separate copy. -pub(super) fn snap_split_forward(history: &[ConversationMessage], proposed_head: usize) -> usize { - let mut head = proposed_head.min(history.len()); - // If the message immediately *before* the split is an - // AssistantToolCalls and the message *at* the split is its - // matching ToolResults, advance past the pair so we don't break - // the API invariant mid-pair. Any other shape (no prev, prev not - // a tool call, or tool call without a matching result right after) - // leaves the split where it was. - if head > 0 - && head < history.len() - && matches!( - &history[head - 1], - ConversationMessage::AssistantToolCalls { .. } - ) - && matches!(&history[head], ConversationMessage::ToolResults(_)) - { - head += 1; - } - // Don't consume the whole history — there'd be no tail to preserve. - if head >= history.len() { - 0 - } else { - head - } -} - -/// `[IMAGE:]` marker prefix — mirrors -/// [`crate::openhuman::agent::multimodal`]. Image attachments ride as these -/// markers inside chat content. -const IMAGE_MARKER_PREFIX: &str = "[IMAGE:"; - -/// Replace each `[IMAGE:]` marker with a short `[image attachment]` -/// placeholder before the content reaches the summarizer. -/// -/// The summarizer is a **text** model fed a plain-text transcript; handing it -/// the raw base64 `data:` URI is both useless (it can't interpret pixels) and -/// harmful — a single 8 MiB image is ~11 M characters, which blows the -/// summarizer's input budget and can fail the compaction turn outright (#3205). -/// We keep a placeholder so the summary still records that an image was present. -fn redact_image_markers(content: &str) -> Cow<'_, str> { - if !content.contains(IMAGE_MARKER_PREFIX) { - return Cow::Borrowed(content); - } - let mut out = String::with_capacity(content.len().min(4096)); - let mut cursor = 0usize; - while let Some(rel) = content[cursor..].find(IMAGE_MARKER_PREFIX) { - let start = cursor + rel; - out.push_str(&content[cursor..start]); - let after = start + IMAGE_MARKER_PREFIX.len(); - match content[after..].find(']') { - Some(rel_end) => { - out.push_str("[image attachment]"); - cursor = after + rel_end + 1; - } - None => { - // Unterminated marker — keep the remainder verbatim and stop. - out.push_str(&content[start..]); - cursor = content.len(); - break; - } - } - } - out.push_str(&content[cursor..]); - Cow::Owned(out) -} - -/// Render a slice of `ConversationMessage` as a plain-text transcript -/// for the summarizer prompt. Format is intentionally simple — the -/// summarizer reads it as-is. -fn render_transcript(msgs: &[ConversationMessage]) -> String { - let mut out = String::new(); - for (i, msg) in msgs.iter().enumerate() { - if i > 0 { - out.push('\n'); - } - match msg { - ConversationMessage::Chat(m) => { - // Strip image base64 — the summarizer can't read pixels and the - // payload would blow its input budget (#3205). - let _ = writeln!( - &mut out, - "[{i}] {}: {}", - m.role, - redact_image_markers(&m.content) - ); - } - ConversationMessage::AssistantToolCalls { - text, tool_calls, .. - } => { - if let Some(t) = text.as_deref() { - if !t.is_empty() { - let _ = writeln!(&mut out, "[{i}] assistant: {t}"); - } - } - for tc in tool_calls { - let _ = writeln!( - &mut out, - "[{i}] assistant tool_call: {}({})", - tc.name, tc.arguments - ); - } - } - ConversationMessage::ToolResults(results) => { - for r in results { - let _ = writeln!( - &mut out, - "[{i}] tool_result({}): {}", - r.tool_call_id, r.content - ); - } - } - } - } - out -} - -/// Opt-in configuration for engine-level autocompaction. -/// -/// The main `Agent` path drives summarization through its typed -/// [`super::ContextManager`] (via the turn engine's `before_dispatch` hook), so -/// it leaves this `None`. Sub-agents have no `ContextManager` — they run the -/// shared turn engine directly over a flat `Vec` — so they pass -/// `Some(_)` to make the engine summarize in place when the context guard -/// reports the window is filling up. See [`summarize_chat_history`]. -#[derive(Debug, Clone)] -pub struct EngineAutocompact { - /// Most-recent messages preserved verbatim at the tail. - pub keep_recent: usize, - /// Temperature for the summarization call. - pub temperature: f64, - /// Optional cheaper model for the summary call; falls back to the agent's - /// own model when `None`. - pub summarizer_model: Option, -} - -impl EngineAutocompact { - /// Construct with the shared summarizer defaults ([`DEFAULT_KEEP_RECENT`], - /// [`DEFAULT_SUMMARIZER_TEMPERATURE`]) and no model override. - pub fn with_defaults(summarizer_model: Option) -> Self { - Self { - keep_recent: DEFAULT_KEEP_RECENT, - temperature: DEFAULT_SUMMARIZER_TEMPERATURE, - summarizer_model, - } - } -} - -/// Summarize a flat `ChatMessage` history in place, mirroring -/// [`ProviderSummarizer::summarize`] but for the sub-agent turn engine, which -/// has no typed [`ConversationMessage`] history. -/// -/// Differences from the typed path, both required by the `ChatMessage` shape: -/// -/// 1. **A leading `system` message is protected**, never summarized — for -/// sub-agents `history[0]` is the rendered system prompt (role contract, -/// tools, identity) and must survive compaction verbatim. -/// 2. **The tail is snapped off any orphan `role:tool` messages.** A tool -/// result whose originating assistant call lands in the summarized head -/// would be rejected by strict providers ("no tool call for result"), so we -/// push the boundary forward until the tail starts on a clean message. -/// -/// Follows the same no-partial-mutation contract: on any early return or error -/// `history` is left untouched, so [`super::ContextGuard`]'s circuit breaker can -/// treat failure as "nothing happened". -pub async fn summarize_chat_history( - provider: &dyn Provider, - history: &mut Vec, - model: &str, - keep_recent: usize, - temperature: f64, -) -> Result { - let total = history.len(); - - // Protect a leading system message (the agent's system prompt). - let head_start = usize::from(history.first().map(|m| m.role == "system").unwrap_or(false)); - - // Need the protected head + something to summarize + the preserved tail. - if total <= head_start + keep_recent { - tracing::debug!( - total, - head_start, - keep_recent, - "[context::chat_summarizer] nothing to summarize — history below keep_recent" - ); - return Ok(SummaryStats::default()); - } - - // Head end = everything before the preserved tail … - let mut head_end = total - keep_recent; - if head_end <= head_start { - return Ok(SummaryStats::default()); - } - // … snapped forward past any orphan tool results so the tail starts clean. - while head_end < total && history[head_end].role == "tool" { - head_end += 1; - } - if head_end >= total { - // Snapping consumed the whole tail — nothing safe to summarize. - return Ok(SummaryStats::default()); - } - - let transcript = render_chat_transcript(&history[head_start..head_end]); - let approx_input_bytes = transcript.len(); - let messages = build_summary_request(&transcript); - - tracing::info!( - model, - head_messages = head_end - head_start, - protected_head = head_start, - tail_preserved = total - head_end, - approx_input_bytes, - "[context::chat_summarizer] dispatching sub-agent autocompaction summary" - ); - - let response = provider - .chat_with_history(&messages, model, temperature) - .await - .map_err(|e| { - tracing::warn!(error = %e, "[context::chat_summarizer] provider call failed"); - e - })?; - - let summary = response.trim(); - if summary.is_empty() { - anyhow::bail!("summarizer returned empty response"); - } - - let summary_body = build_summary_message_body(summary); - let summary_chars = summary_body.len(); - let approx_tokens_freed = (approx_input_bytes as u64) - .saturating_sub(summary_chars as u64) - .div_ceil(4); - let messages_removed = head_end - head_start; - - // Replace [head_start, head_end) with one `system` summary message. The - // protected leading system prompt (if any) and the verbatim tail are kept. - history.splice( - head_start..head_end, - std::iter::once(ChatMessage::system(summary_body)), - ); - - tracing::info!( - messages_removed, - approx_tokens_freed, - summary_chars, - "[context::chat_summarizer] sub-agent autocompaction complete" - ); - - Ok(SummaryStats { - messages_removed, - approx_tokens_freed, - summary_chars, - }) -} - -/// Render a slice of `ChatMessage` as the plain-text transcript the summarizer -/// reads. Image markers are stripped (the summarizer is a text model and a raw -/// base64 data URI would blow its input budget — see [`redact_image_markers`]). -fn render_chat_transcript(msgs: &[ChatMessage]) -> String { - let mut out = String::new(); - for (i, m) in msgs.iter().enumerate() { - if i > 0 { - out.push('\n'); - } - let _ = writeln!( - &mut out, - "[{i}] {}: {}", - m.role, - redact_image_markers(&m.content) - ); - } - out -} - -#[cfg(test)] -#[path = "summarizer_tests.rs"] -mod tests; diff --git a/src/openhuman/context/summarizer_tests.rs b/src/openhuman/context/summarizer_tests.rs deleted file mode 100644 index a8facfa60..000000000 --- a/src/openhuman/context/summarizer_tests.rs +++ /dev/null @@ -1,383 +0,0 @@ -use super::*; -use crate::openhuman::inference::provider::{ChatResponse, ToolCall, ToolResultMessage}; -use async_trait::async_trait; -use std::sync::Mutex; - -fn user(text: &str) -> ConversationMessage { - ConversationMessage::Chat(ChatMessage::user(text)) -} - -fn assistant(text: &str) -> ConversationMessage { - ConversationMessage::Chat(ChatMessage::assistant(text)) -} - -fn call(id: &str) -> ConversationMessage { - ConversationMessage::AssistantToolCalls { - text: None, - tool_calls: vec![ToolCall { - id: id.into(), - name: "t".into(), - arguments: "{}".into(), - extra_content: None, - }], - reasoning_content: None, - extra_metadata: None, - } -} - -fn result(id: &str, body: &str) -> ConversationMessage { - ConversationMessage::ToolResults(vec![ToolResultMessage { - tool_call_id: id.into(), - content: body.into(), - }]) -} - -/// Minimal Provider that returns a pinned reply for every call. -/// Records how many times `chat_with_history` fired so tests can -/// assert the summarizer skipped the provider round-trip when it -/// should have. -struct StubProvider { - reply: String, - calls: Mutex, -} - -impl StubProvider { - fn new(reply: impl Into) -> Self { - Self { - reply: reply.into(), - calls: Mutex::new(0), - } - } - fn call_count(&self) -> usize { - *self.calls.lock().unwrap() - } -} - -#[async_trait] -impl Provider for StubProvider { - async fn chat_with_system( - &self, - _system: Option<&str>, - _message: &str, - _model: &str, - _temperature: f64, - ) -> anyhow::Result { - *self.calls.lock().unwrap() += 1; - Ok(self.reply.clone()) - } - - async fn chat_with_history( - &self, - _messages: &[ChatMessage], - _model: &str, - _temperature: f64, - ) -> anyhow::Result { - *self.calls.lock().unwrap() += 1; - Ok(self.reply.clone()) - } - - async fn chat( - &self, - _request: crate::openhuman::inference::provider::ChatRequest<'_>, - _model: &str, - _temperature: f64, - ) -> anyhow::Result { - *self.calls.lock().unwrap() += 1; - Ok(ChatResponse { - text: Some(self.reply.clone()), - tool_calls: vec![], - usage: None, - reasoning_content: None, - }) - } -} - -#[tokio::test] -async fn noop_when_history_below_keep_recent() { - let provider = Arc::new(StubProvider::new("IRRELEVANT")); - let summarizer = ProviderSummarizer::new(provider.clone()).with_keep_recent(10); - - let mut history = vec![user("hi"), assistant("hello")]; - let stats = summarizer - .summarize(&mut history, "test-model") - .await - .unwrap(); - - assert_eq!(stats.messages_removed, 0); - assert_eq!(history.len(), 2); - assert_eq!(provider.call_count(), 0, "must not call provider on no-op"); -} - -#[tokio::test] -async fn summarizes_long_history_and_replaces_head() { - let provider = Arc::new(StubProvider::new("SUMMARY_BODY")); - let summarizer = ProviderSummarizer::new(provider.clone()).with_keep_recent(2); - - // 6 older messages + 2 tail = 8 total; head should collapse to 1 - // system message, tail of 2 preserved. - let mut history = vec![ - user("q1"), - assistant("a1"), - user("q2"), - assistant("a2"), - user("q3"), - assistant("a3"), - user("q4-tail"), - assistant("a4-tail"), - ]; - - let stats = summarizer - .summarize(&mut history, "test-model") - .await - .unwrap(); - - assert_eq!(stats.messages_removed, 6); - assert_eq!(history.len(), 3, "1 summary + 2 tail"); - assert_eq!(provider.call_count(), 1); - - // First message must be a system summary containing the stub reply. - match &history[0] { - ConversationMessage::Chat(m) => { - assert_eq!(m.role, "system"); - assert!(m.content.contains("SUMMARY_BODY")); - assert!(m.content.contains("REFERENCE ONLY")); - assert!(m.content.contains("END OF CONTEXT SUMMARY")); - } - other => panic!("expected system summary, got {other:?}"), - } - // Tail preserved verbatim. - match &history[1] { - ConversationMessage::Chat(m) => assert_eq!(m.content, "q4-tail"), - _ => panic!(), - } - match &history[2] { - ConversationMessage::Chat(m) => assert_eq!(m.content, "a4-tail"), - _ => panic!(), - } -} - -#[tokio::test] -async fn snaps_split_past_tool_result_pair() { - // Proposed head = 3 would land between `call("t1")` and its - // matching `result("t1")` — the snap should push it to 4 so - // the AssistantToolCalls ↔ ToolResults pair stays together. - let provider = Arc::new(StubProvider::new("SUMMARY")); - let summarizer = ProviderSummarizer::new(provider.clone()).with_keep_recent(2); - - let mut history = vec![ - user("q"), - assistant("ack"), - call("t1"), - result("t1", "r1"), - user("tail-q"), - assistant("tail-a"), - ]; - - let _ = summarizer - .summarize(&mut history, "test-model") - .await - .unwrap(); - - // Expect 1 summary + 2-tail + maybe nothing between. Because - // the head was snapped to 4, the resulting history is: - // [system-summary, user("tail-q"), assistant("tail-a")] - assert_eq!(history.len(), 3); - match &history[0] { - ConversationMessage::Chat(m) => { - assert_eq!(m.role, "system"); - assert!(m.content.contains("SUMMARY")); - } - _ => panic!(), - } -} - -#[tokio::test] -async fn empty_summary_errors_and_leaves_history_untouched() { - let provider = Arc::new(StubProvider::new(" \n\t ")); - let summarizer = ProviderSummarizer::new(provider).with_keep_recent(1); - - let mut history = vec![user("q1"), assistant("a1"), user("q2-tail")]; - let before = history.clone(); - - let err = summarizer - .summarize(&mut history, "test-model") - .await - .unwrap_err(); - assert!(err.to_string().contains("empty")); - - // History must be untouched on error. - assert_eq!(history.len(), before.len()); -} - -#[test] -fn transcript_renders_all_message_variants() { - let msgs = vec![ - user("hello"), - assistant("hi"), - ConversationMessage::AssistantToolCalls { - text: Some("let me check".into()), - tool_calls: vec![ToolCall { - id: "1".into(), - name: "shell".into(), - arguments: r#"{"cmd":"ls"}"#.into(), - extra_content: None, - }], - reasoning_content: None, - extra_metadata: None, - }, - result("1", "file.txt"), - ]; - let rendered = render_transcript(&msgs); - assert!(rendered.contains("user: hello")); - assert!(rendered.contains("assistant: hi")); - assert!(rendered.contains("assistant: let me check")); - assert!(rendered.contains("assistant tool_call: shell(")); - assert!(rendered.contains("tool_result(1): file.txt")); -} - -// ── #3205: keep image base64 out of the summarizer transcript ─────────────── - -#[test] -fn redact_image_markers_passes_through_markerless_text() { - let s = "just a normal message with no attachments"; - assert!(matches!(redact_image_markers(s), Cow::Borrowed(b) if b == s)); -} - -#[test] -fn redact_image_markers_replaces_marker_with_placeholder() { - let out = - redact_image_markers("look at this [IMAGE:data:image/png;base64,iVBORw0KGgoAAAA=] please"); - assert_eq!(out, "look at this [image attachment] please"); - assert!(!out.contains("base64")); -} - -#[test] -fn redact_image_markers_handles_multiple_markers() { - let out = redact_image_markers("[IMAGE:data:image/png;base64,AAA] and [IMAGE:https://x/y.jpg]"); - assert_eq!(out, "[image attachment] and [image attachment]"); -} - -#[test] -fn render_transcript_strips_image_base64() { - let big = format!( - "describe [IMAGE:data:image/png;base64,{}]", - "Q".repeat(50_000) - ); - let history = vec![ConversationMessage::Chat(ChatMessage::user(&big))]; - let rendered = render_transcript(&history); - assert!(rendered.contains("[image attachment]")); - assert!(!rendered.contains("base64")); - assert!(!rendered.contains("QQQQ")); - // The 50k-char base64 payload must not survive into the summarizer input. - assert!(rendered.len() < 200); -} - -// ── ChatMessage-level summarizer (sub-agent engine path) ──────────────────── - -#[tokio::test] -async fn chat_summary_noop_when_below_keep_recent() { - let provider = Arc::new(StubProvider::new("IRRELEVANT")); - let mut history = vec![ - ChatMessage::system("sys"), - ChatMessage::user("hi"), - ChatMessage::assistant("hello"), - ]; - - let stats = summarize_chat_history(provider.as_ref(), &mut history, "m", 10, 0.2) - .await - .unwrap(); - - assert_eq!(stats.messages_removed, 0); - assert_eq!(history.len(), 3); - assert_eq!(provider.call_count(), 0, "must not call provider on no-op"); -} - -#[tokio::test] -async fn chat_summary_protects_leading_system_and_replaces_middle() { - let provider = Arc::new(StubProvider::new("SUMMARY_BODY")); - let mut history = vec![ - ChatMessage::system("SYSTEM-PROMPT"), - ChatMessage::user("q1"), - ChatMessage::assistant("a1"), - ChatMessage::user("q2"), - ChatMessage::assistant("a2"), - ChatMessage::user("q3-tail"), - ChatMessage::assistant("a3-tail"), - ]; - - let stats = summarize_chat_history(provider.as_ref(), &mut history, "m", 2, 0.2) - .await - .unwrap(); - - // System prompt protected; middle 4 collapsed to 1 summary; 2-tail kept. - assert_eq!(stats.messages_removed, 4); - assert_eq!(history.len(), 4, "system + summary + 2 tail"); - assert_eq!(provider.call_count(), 1); - - // Leading system prompt preserved verbatim. - assert_eq!(history[0].role, "system"); - assert_eq!(history[0].content, "SYSTEM-PROMPT"); - - // Inserted summary sits right after it, framed as reference-only. - assert_eq!(history[1].role, "system"); - assert!(history[1].content.contains("SUMMARY_BODY")); - assert!(history[1].content.contains("REFERENCE ONLY")); - assert!(history[1].content.contains("END OF CONTEXT SUMMARY")); - - // Tail preserved verbatim. - assert_eq!(history[2].content, "q3-tail"); - assert_eq!(history[3].content, "a3-tail"); -} - -#[tokio::test] -async fn chat_summary_snaps_tail_off_orphan_tool_result() { - // With keep_recent=2 the proposed tail would begin at the `tool` result, - // orphaning it from the assistant message that requested it. The snap must - // pull the boundary forward so the kept tail starts on a clean message. - let provider = Arc::new(StubProvider::new("SUMMARY")); - let mut history = vec![ - ChatMessage::system("sys"), - ChatMessage::user("q"), - ChatMessage::assistant("calling tool"), - ChatMessage::tool("tool-result"), - ChatMessage::user("tail-q"), - ChatMessage::assistant("tail-a"), - ]; - - summarize_chat_history(provider.as_ref(), &mut history, "m", 2, 0.2) - .await - .unwrap(); - - // No kept message may be an orphan `tool` result, and the system prompt - // stays at the head. - assert_eq!(history[0].content, "sys"); - assert_eq!(history[1].role, "system", "summary message"); - assert!( - history.iter().all(|m| m.role != "tool"), - "orphan tool result must be folded into the summarized head" - ); - // Tail kept verbatim. - assert_eq!(history[history.len() - 2].content, "tail-q"); - assert_eq!(history[history.len() - 1].content, "tail-a"); -} - -#[tokio::test] -async fn chat_summary_empty_response_errors_and_leaves_history_untouched() { - let provider = Arc::new(StubProvider::new(" \n\t ")); - let mut history = vec![ - ChatMessage::system("sys"), - ChatMessage::user("q1"), - ChatMessage::assistant("a1"), - ChatMessage::user("q2-tail"), - ]; - let before: Vec = history.iter().map(|m| m.content.clone()).collect(); - - let err = summarize_chat_history(provider.as_ref(), &mut history, "m", 1, 0.2) - .await - .unwrap_err(); - assert!(err.to_string().contains("empty")); - - // No partial mutation on failure. - let after: Vec = history.iter().map(|m| m.content.clone()).collect(); - assert_eq!(before, after); -} diff --git a/src/openhuman/cost/catalog.rs b/src/openhuman/cost/catalog.rs index 1d2ef2dc9..16b4ad088 100644 --- a/src/openhuman/cost/catalog.rs +++ b/src/openhuman/cost/catalog.rs @@ -378,6 +378,32 @@ pub fn context_window(model: &str) -> Option { lookup(model).map(|p| p.context_window) } +/// Estimate the USD cost of a single model call from catalogued per-MTok rates. +/// +/// Prices the standard (cache-miss) input tokens, the cached-prefix input +/// tokens, and the output tokens separately. `cached_input_tokens` are billed at +/// the (usually cheaper) cached rate and are assumed to be a subset of +/// `input_tokens`, so the standard-rate portion is `input − cached`. Returns +/// `0.0` when the model is not catalogued (caller should treat as "unknown, not +/// free" — this is a best-effort estimate used when the provider does not report +/// a charged amount). +pub fn estimate_cost_usd( + model: &str, + input_tokens: u64, + output_tokens: u64, + cached_input_tokens: u64, +) -> f64 { + let Some(p) = lookup(model) else { + return 0.0; + }; + let cached = cached_input_tokens.min(input_tokens); + let standard_input = input_tokens.saturating_sub(cached); + let per_tok = |mtok_rate: f64| mtok_rate / 1_000_000.0; + (standard_input as f64) * per_tok(p.input_per_mtok_usd) + + (cached as f64) * per_tok(p.cached_input_per_mtok_usd) + + (output_tokens as f64) * per_tok(p.output_per_mtok_usd) +} + /// Build a default registry, one [`ModelRegistryEntry`] per catalogued model /// with prices and context window pre-filled. Used to seed an empty /// `config.model_registry`. @@ -551,4 +577,62 @@ mod tests { ); } } + + // ── estimate_cost_usd (issue #4249, Phase 5 — the $0-cost turn fix) ────── + + fn approx(a: f64, b: f64) { + assert!((a - b).abs() < 1e-9, "expected {b}, got {a}"); + } + + #[test] + fn estimate_prices_standard_input_and_output() { + // opus-4-8: $5/$25 per MTok in/out. 1M in + 1M out, no cache. + approx( + estimate_cost_usd("claude-opus-4-8", 1_000_000, 1_000_000, 0), + 30.00, + ); + } + + #[test] + fn estimate_bills_cached_prefix_at_the_cheaper_rate() { + // Fully cached input (cached == input) → cached rate only ($0.50/MTok). + approx( + estimate_cost_usd("claude-opus-4-8", 1_000_000, 0, 1_000_000), + 0.50, + ); + // Half cached: 0.5M standard @ $5 + 0.5M cached @ $0.50 = 2.50 + 0.25. + approx( + estimate_cost_usd("claude-opus-4-8", 1_000_000, 0, 500_000), + 2.75, + ); + } + + #[test] + fn estimate_clamps_cached_to_input() { + // cached_input_tokens > input_tokens must not underflow or overcharge: + // it is clamped to input, so this is billed as fully cached. + approx( + estimate_cost_usd("claude-opus-4-8", 1_000_000, 0, 5_000_000), + 0.50, + ); + } + + #[test] + fn estimate_returns_zero_for_uncatalogued_models() { + // "unknown, not free" — the caller treats 0.0 as no estimate available. + assert_eq!( + estimate_cost_usd("totally-made-up-model", 1_000_000, 1_000_000, 0), + 0.0 + ); + } + + #[test] + fn estimate_resolves_decorated_model_ids() { + // The catalog lookup normalizes tags/suffixes, so a decorated id + // (e.g. the runtime "[1m]" window tag) still prices correctly. + approx( + estimate_cost_usd("claude-opus-4-8[1m]", 1_000_000, 0, 0), + 5.00, + ); + } } diff --git a/src/openhuman/mod.rs b/src/openhuman/mod.rs index f0c2b11cf..f05555148 100644 --- a/src/openhuman/mod.rs +++ b/src/openhuman/mod.rs @@ -119,6 +119,7 @@ pub mod test_support; pub mod text_input; pub mod thread_goals; pub mod threads; +pub mod tinyagents; pub mod tinyplace; pub mod tls; pub mod todos; diff --git a/src/openhuman/model_council/council.rs b/src/openhuman/model_council/council.rs index 46be13fff..374d73947 100644 --- a/src/openhuman/model_council/council.rs +++ b/src/openhuman/model_council/council.rs @@ -30,6 +30,8 @@ //! I/O orchestrator ([`run_council`]) so the deliberation logic is unit-tested //! without any network or provider. +use std::sync::Arc; + use serde::{Deserialize, Serialize}; use crate::openhuman::agent::Agent; @@ -252,10 +254,15 @@ pub async fn run_council( temperature ); - let member_futures = models - .iter() - .map(|model| run_member_answer_inner(config, question, model, temperature)); - let members: Vec = futures_util::future::join_all(member_futures).await; + // Fan out the member seats on the tinyagents graph (StateGraph + parallel + // super-step + reducer) rather than a hand-rolled `join_all` (issue #4249). + let members: Vec = super::graph::run_council_members_via_graph( + Arc::new(config.clone()), + Arc::from(question), + models.clone(), + temperature, + ) + .await?; let success_count = members.iter().filter(|m| m.response.is_some()).count(); log::debug!( @@ -347,7 +354,7 @@ pub async fn synthesize_members( )) } -async fn run_member_answer_inner( +pub(super) async fn run_member_answer_inner( config: &Config, question: &str, model: &str, diff --git a/src/openhuman/model_council/graph.rs b/src/openhuman/model_council/graph.rs new file mode 100644 index 000000000..3e6c29cae --- /dev/null +++ b/src/openhuman/model_council/graph.rs @@ -0,0 +1,274 @@ +//! Parallel council fan-out expressed on the `tinyagents` graph layer (#4249, #27). +//! +//! This is the `model_council` folder's `graph.rs` per the per-folder graph +//! convention: the folder's tinyagents fan-out graph definition lives here; +//! `council.rs` drives it. +//! +//! The council runs N member models concurrently, then a chair synthesizes their +//! answers. Historically the fan-out was a hand-rolled +//! [`futures_util::future::join_all`]; this module re-expresses the *map* half as +//! a real `tinyagents` [`CompiledGraph`]: +//! +//! ```text +//! ┌─ member_0 ─┐ +//! dispatch ┼─ member_1 ─┼─ collect (finish) +//! └─ member_n ─┘ +//! ``` +//! +//! `dispatch` is a command-routing node that fans out to every member via +//! [`Command::with_goto`]; the members run in the same superstep (`with_parallel`) +//! and each writes its [`CouncilMemberResult`] into the graph state through the +//! reducer; `collect` is the fan-in barrier that the executor only schedules once +//! every member edge has fired. The chair synthesis stays outside the graph (it +//! is a single sequential call), matching the previous control flow. +//! +//! The member runner is injected ([`run_member_fanout`]) so the graph mechanics +//! (typed state + reducer + parallel super-steps + fan-in barrier) are unit +//! tested with a trivial closure, while production passes the real provider call. +//! This is the first openhuman feature driven on the SDK's StateGraph primitives. + +use std::future::Future; +use std::sync::Arc; + +use tinyagents::graph::ClosureStateReducer; +use tinyagents::graph::{Command, GraphBuilder, NodeContext, NodeResult}; + +use crate::openhuman::config::Config; +use crate::openhuman::tinyagents::observability::GraphTracingSink; + +use super::council::{run_member_answer_inner, CouncilMemberResult}; + +/// Typed working state threaded through the council graph: one slot per member, +/// filled in by the reducer as each member node completes (in any order). +#[derive(Clone, Default)] +struct CouncilState { + members: Vec>, +} + +/// Reducer updates emitted by the graph nodes. +enum CouncilUpdate { + /// A member seat finished; store its result at `index`. + Member { + index: usize, + result: Box, + }, + /// The fan-in `collect` node fired; it carries no state change. + Noop, +} + +/// Run the council member fan-out on the tinyagents graph and return the member +/// results in seat order. The chair synthesis is performed by the caller. +/// +/// `models` is the already-normalized member model list; `config`/`question` are +/// cloned into the node closures (the graph requires `'static` handlers). +pub async fn run_council_members_via_graph( + config: Arc, + question: Arc, + models: Vec, + temperature: Option, +) -> Result, String> { + run_member_fanout(models, move |model| { + let config = config.clone(); + let question = question.clone(); + async move { run_member_answer_inner(&config, &question, &model, temperature).await } + }) + .await +} + +/// Build and run the parallel member fan-out graph, invoking `run_one(model)` for +/// each seat. Pure graph mechanics — no provider knowledge — so it is unit +/// testable with a mock runner. +async fn run_member_fanout( + models: Vec, + run_one: F, +) -> Result, String> +where + F: Fn(String) -> Fut + Clone + Send + Sync + 'static, + Fut: Future + Send + 'static, +{ + let n = models.len(); + let member_ids: Vec = (0..n).map(|i| format!("member_{i}")).collect(); + + let mut builder = GraphBuilder::::new() + .with_parallel(true) + .with_max_concurrency(n.max(1)) + .set_reducer(ClosureStateReducer::new( + |mut s: CouncilState, u: CouncilUpdate| { + if let CouncilUpdate::Member { index, result } = u { + if let Some(slot) = s.members.get_mut(index) { + *slot = Some(*result); + } + } + Ok(s) + }, + )); + + // `dispatch`: command-routing entry that fans out to every member seat. + let goto_ids = member_ids.clone(); + builder = builder.add_node("dispatch", move |_s: CouncilState, _c: NodeContext| { + let goto_ids = goto_ids.clone(); + async move { Ok(NodeResult::Command(Command::default().with_goto(goto_ids))) } + }); + + // One node per member seat: runs the member answer and writes it back. + for (i, model) in models.into_iter().enumerate() { + let run_one = run_one.clone(); + let node_id = member_ids[i].clone(); + builder = builder.add_node(node_id.clone(), move |_s: CouncilState, _c: NodeContext| { + let run_one = run_one.clone(); + let model = model.clone(); + async move { + let result = run_one(model).await; + Ok(NodeResult::Update(CouncilUpdate::Member { + index: i, + result: Box::new(result), + })) + } + }); + // Every seat fans into the `collect` barrier. + builder = builder.add_edge(node_id, "collect"); + } + + // `collect`: fan-in barrier the executor only runs once every member edge + // fired. It leaves the accumulated state untouched and finishes the graph. + builder = builder + .add_node("collect", |_s: CouncilState, _c: NodeContext| async move { + Ok(NodeResult::Update(CouncilUpdate::Noop)) + }) + .set_entry("dispatch") + .mark_command_routing("dispatch") + .set_finish("collect"); + + let graph = builder + .compile() + .map_err(|e| format!("council graph compile failed: {e}"))? + // Mirror the executor's node/run lifecycle onto tracing (#28 observability). + .with_event_sink(Arc::new(GraphTracingSink::new("council:graph"))); + + tracing::debug!( + members = n, + "[model-council] running member fan-out on tinyagents graph" + ); + let execution = graph + .run(CouncilState { + members: vec![None; n], + }) + .await + .map_err(|e| format!("council graph run failed: {e}"))?; + + // Every member node ran (each seat has an edge the executor must traverse), + // so every slot is populated; fall back defensively to a failure result. + let results = execution + .state + .members + .into_iter() + .enumerate() + .map(|(i, slot)| { + slot.unwrap_or_else(|| { + tracing::warn!( + seat = i, + "[model-council] member slot empty after graph run" + ); + CouncilMemberResult { + model: "unknown".to_string(), + response: None, + error: Some("member seat produced no result on the graph path".to_string()), + } + }) + }) + .collect(); + Ok(results) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicUsize, Ordering}; + + /// The fan-out preserves seat order regardless of completion order, runs every + /// seat, and merges each result into the right slot via the reducer. + #[tokio::test] + async fn fanout_runs_every_seat_and_preserves_order() { + let models = vec!["m-a".to_string(), "m-b".to_string(), "m-c".to_string()]; + let ran = Arc::new(AtomicUsize::new(0)); + let ran2 = ran.clone(); + let results = run_member_fanout(models, move |model| { + let ran = ran2.clone(); + async move { + ran.fetch_add(1, Ordering::SeqCst); + CouncilMemberResult { + model: model.clone(), + response: Some(format!("answer from {model}")), + error: None, + } + } + }) + .await + .expect("graph fan-out runs"); + + assert_eq!( + ran.load(Ordering::SeqCst), + 3, + "every seat node executed once" + ); + assert_eq!(results.len(), 3, "one result per seat"); + // Results are returned in seat (input) order, not completion order. + assert_eq!(results[0].model, "m-a"); + assert_eq!(results[1].model, "m-b"); + assert_eq!(results[2].model, "m-c"); + assert_eq!( + results[2].response.as_deref(), + Some("answer from m-c"), + "each seat's result lands in its own slot" + ); + } + + /// The observability sink receives the executor's lifecycle events for every + /// node in the fan-out (run + node start/complete across the supersteps). + #[tokio::test] + async fn tracing_sink_receives_graph_lifecycle_events() { + let sink = GraphTracingSink::new("test:graph"); + let counter = sink.counter(); + + let graph = GraphBuilder::::new() + .set_reducer(ClosureStateReducer::new( + |s: CouncilState, _u: CouncilUpdate| Ok(s), + )) + .add_node("solo", |_s: CouncilState, _c: NodeContext| async move { + Ok(NodeResult::Update(CouncilUpdate::Noop)) + }) + .set_entry("solo") + .set_finish("solo") + .compile() + .expect("compiles") + .with_event_sink(Arc::new(sink)); + + graph + .run(CouncilState::default()) + .await + .expect("graph runs"); + + // At minimum: RunStarted + NodeStarted + NodeCompleted + RunCompleted. + assert!( + counter.load(Ordering::Relaxed) >= 4, + "sink should observe the run+node lifecycle events, saw {}", + counter.load(Ordering::Relaxed) + ); + } + + /// A single-member council still builds a valid graph (one branch + barrier). + #[tokio::test] + async fn fanout_handles_single_member() { + let results = run_member_fanout(vec!["solo".to_string()], move |model| async move { + CouncilMemberResult { + model, + response: Some("only answer".to_string()), + error: None, + } + }) + .await + .expect("single-member graph runs"); + assert_eq!(results.len(), 1); + assert_eq!(results[0].response.as_deref(), Some("only answer")); + } +} diff --git a/src/openhuman/model_council/mod.rs b/src/openhuman/model_council/mod.rs index 988b9aee1..646a02687 100644 --- a/src/openhuman/model_council/mod.rs +++ b/src/openhuman/model_council/mod.rs @@ -6,6 +6,7 @@ //! controller surface (`openhuman.model_council_run`). pub mod council; +mod graph; mod schemas; pub use schemas::{ diff --git a/src/openhuman/session_db/run_ledger/store.rs b/src/openhuman/session_db/run_ledger/store.rs index 938892701..83aa083ac 100644 --- a/src/openhuman/session_db/run_ledger/store.rs +++ b/src/openhuman/session_db/run_ledger/store.rs @@ -119,7 +119,24 @@ pub(crate) fn init_run_ledger_schema(conn: &Connection) -> Result<()> { ); CREATE INDEX IF NOT EXISTS idx_agent_team_tasks_team ON agent_team_tasks(team_id); CREATE INDEX IF NOT EXISTS idx_agent_team_tasks_status ON agent_team_tasks(status); - CREATE INDEX IF NOT EXISTS idx_agent_team_tasks_claimed ON agent_team_tasks(claimed_by_member_id);", + CREATE INDEX IF NOT EXISTS idx_agent_team_tasks_claimed ON agent_team_tasks(claimed_by_member_id); + + -- Durable tinyagents graph checkpoints (issue #4249). One row per + -- superstep-boundary snapshot, keyed by thread_id; `record_json` holds the + -- full serialized `tinyagents::graph::Checkpoint`. `seq` preserves + -- insertion order so the latest checkpoint and `list` ordering are exact. + -- Backs `SqlRunLedgerCheckpointer` (the openhuman-SQLite stand-in for the + -- crate's dependency-blocked SqliteCheckpointer). + CREATE TABLE IF NOT EXISTS graph_checkpoints ( + seq INTEGER PRIMARY KEY AUTOINCREMENT, + thread_id TEXT NOT NULL, + checkpoint_id TEXT NOT NULL, + run_id TEXT, + record_json TEXT NOT NULL, + created_at TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_graph_checkpoints_thread ON graph_checkpoints(thread_id, seq); + CREATE INDEX IF NOT EXISTS idx_graph_checkpoints_cid ON graph_checkpoints(thread_id, checkpoint_id);", ) .context("failed to initialize run ledger schema") } diff --git a/src/openhuman/skill_registry/agent/skill_setup/graph.rs b/src/openhuman/skill_registry/agent/skill_setup/graph.rs new file mode 100644 index 000000000..4ce5527ae --- /dev/null +++ b/src/openhuman/skill_registry/agent/skill_setup/graph.rs @@ -0,0 +1,13 @@ +//! Turn graph for the `skill_setup` built-in agent. +//! +//! Uses the shared default sub-agent turn graph (`run_subagent_via_graph`) — see +//! [`crate::openhuman::agent::harness::agent_graph`]. Replace the body with +//! `AgentGraph::custom(run)` to give this agent a bespoke tinyagents graph. + +use crate::openhuman::agent::harness::agent_graph::AgentGraph; + +/// Select this agent's turn graph. This is a default agent — it uses the shared +/// default graph rather than defining its own. +pub fn graph() -> AgentGraph { + AgentGraph::Default +} diff --git a/src/openhuman/skill_registry/agent/skill_setup/mod.rs b/src/openhuman/skill_registry/agent/skill_setup/mod.rs index 8bf84783c..cedb79d0e 100644 --- a/src/openhuman/skill_registry/agent/skill_setup/mod.rs +++ b/src/openhuman/skill_registry/agent/skill_setup/mod.rs @@ -1 +1,2 @@ +pub mod graph; pub mod prompt; diff --git a/src/openhuman/skill_runtime/agent/skill_executor/graph.rs b/src/openhuman/skill_runtime/agent/skill_executor/graph.rs new file mode 100644 index 000000000..208fa363a --- /dev/null +++ b/src/openhuman/skill_runtime/agent/skill_executor/graph.rs @@ -0,0 +1,13 @@ +//! Turn graph for the `skill_executor` built-in agent. +//! +//! Uses the shared default sub-agent turn graph (`run_subagent_via_graph`) — see +//! [`crate::openhuman::agent::harness::agent_graph`]. Replace the body with +//! `AgentGraph::custom(run)` to give this agent a bespoke tinyagents graph. + +use crate::openhuman::agent::harness::agent_graph::AgentGraph; + +/// Select this agent's turn graph. This is a default agent — it uses the shared +/// default graph rather than defining its own. +pub fn graph() -> AgentGraph { + AgentGraph::Default +} diff --git a/src/openhuman/skill_runtime/agent/skill_executor/mod.rs b/src/openhuman/skill_runtime/agent/skill_executor/mod.rs index 8bf84783c..cedb79d0e 100644 --- a/src/openhuman/skill_runtime/agent/skill_executor/mod.rs +++ b/src/openhuman/skill_runtime/agent/skill_executor/mod.rs @@ -1 +1,2 @@ +pub mod graph; pub mod prompt; diff --git a/src/openhuman/subconscious/agent/graph.rs b/src/openhuman/subconscious/agent/graph.rs new file mode 100644 index 000000000..b9dd9b8db --- /dev/null +++ b/src/openhuman/subconscious/agent/graph.rs @@ -0,0 +1,13 @@ +//! Turn graph for the `subconscious` built-in agent. +//! +//! Uses the shared default sub-agent turn graph (`run_subagent_via_graph`) — see +//! [`crate::openhuman::agent::harness::agent_graph`]. Replace the body with +//! `AgentGraph::custom(run)` to give this agent a bespoke tinyagents graph. + +use crate::openhuman::agent::harness::agent_graph::AgentGraph; + +/// Select this agent's turn graph. This is a default agent — it uses the shared +/// default graph rather than defining its own. +pub fn graph() -> AgentGraph { + AgentGraph::Default +} diff --git a/src/openhuman/subconscious/agent/mod.rs b/src/openhuman/subconscious/agent/mod.rs index 8bf84783c..cedb79d0e 100644 --- a/src/openhuman/subconscious/agent/mod.rs +++ b/src/openhuman/subconscious/agent/mod.rs @@ -1 +1,2 @@ +pub mod graph; pub mod prompt; diff --git a/src/openhuman/tinyagents/checkpoint.rs b/src/openhuman/tinyagents/checkpoint.rs new file mode 100644 index 000000000..31bdfa5dd --- /dev/null +++ b/src/openhuman/tinyagents/checkpoint.rs @@ -0,0 +1,251 @@ +//! SQLite-backed [`Checkpointer`] over openhuman's run ledger (issue #4249). +//! +//! tinyagents 1.1 ships a `SqliteCheckpointer`, but its `sqlite` feature pulls +//! `rusqlite 0.40` / `libsqlite3-sys 0.38`, which conflict with openhuman's own +//! `rusqlite 0.37` over the `links = "sqlite3"` native lib — so it cannot be +//! enabled. The crate's always-available `FileCheckpointer` works, but durable +//! orchestration state needs to stay queryable from the existing run-ledger +//! controllers (workflow/team/command-center read the same `sessions.db`). +//! +//! [`SqlRunLedgerCheckpointer`] is the stand-in: it implements the crate's +//! [`Checkpointer`] trait but persists each superstep-boundary snapshot as a row +//! in the `graph_checkpoints` table (one serialized +//! [`Checkpoint`](tinyagents::graph::Checkpoint) per row, keyed by +//! `thread_id`, `seq`-ordered). It mirrors the reference `FileCheckpointer` +//! semantics exactly — append on `put`, latest-wins `get`, insertion-order +//! `list` — so any durable graph (delegation, workflow, teams) can checkpoint to +//! openhuman SQLite instead of the blocked crate backend. +//! +//! rusqlite is blocking, so every trait method runs the DB work inside +//! [`tokio::task::spawn_blocking`]. + +use std::marker::PhantomData; +use std::sync::Arc; + +use async_trait::async_trait; +use serde::de::DeserializeOwned; +use serde::Serialize; +use tinyagents::graph::checkpoint::{Checkpoint, CheckpointMetadata, Checkpointer}; +use tinyagents::harness::ids::CheckpointId; +use tinyagents::{Result as TaResult, TinyAgentsError}; + +use crate::openhuman::config::Config; +use crate::openhuman::session_db::run_ledger::store::init_run_ledger_schema; +use crate::openhuman::session_db::with_connection; + +/// A [`Checkpointer`] that persists graph checkpoints into the openhuman session +/// DB (`graph_checkpoints` table). Cheap to clone; clones address the same DB. +pub struct SqlRunLedgerCheckpointer { + config: Arc, + _marker: PhantomData State>, +} + +impl SqlRunLedgerCheckpointer { + /// Build a checkpointer backed by the session DB resolved from `config` + /// (`{workspace}/session_db/sessions.db`). + pub fn new(config: Arc) -> Self { + Self { + config, + _marker: PhantomData, + } + } +} + +impl Clone for SqlRunLedgerCheckpointer { + fn clone(&self) -> Self { + Self { + config: self.config.clone(), + _marker: PhantomData, + } + } +} + +/// Map an openhuman `anyhow` DB error onto the crate's checkpoint error variant. +fn db_err(context: &str, err: impl std::fmt::Display) -> TinyAgentsError { + TinyAgentsError::Checkpoint(format!("sql run-ledger checkpointer: {context}: {err}")) +} + +#[async_trait] +impl Checkpointer for SqlRunLedgerCheckpointer +where + State: Serialize + DeserializeOwned + Send + Sync + 'static, +{ + async fn put(&self, checkpoint: Checkpoint) -> TaResult { + let config = self.config.clone(); + let id = CheckpointId::new(checkpoint.checkpoint_id.clone()); + let thread_id = checkpoint.thread_id.clone(); + let checkpoint_id = checkpoint.checkpoint_id.clone(); + let run_id = checkpoint.run_id.clone(); + let record_json = + serde_json::to_string(&checkpoint).map_err(|e| db_err("encode record", e))?; + + tokio::task::spawn_blocking(move || { + with_connection(&config, |conn| { + init_run_ledger_schema(conn)?; + conn.execute( + "INSERT INTO graph_checkpoints \ + (thread_id, checkpoint_id, run_id, record_json, created_at) \ + VALUES (?1, ?2, ?3, ?4, ?5)", + rusqlite::params![ + thread_id, + checkpoint_id, + run_id, + record_json, + chrono::Utc::now().to_rfc3339(), + ], + )?; + Ok(()) + }) + .map_err(|e| db_err("put", e)) + }) + .await + .map_err(|e| db_err("put join", e))??; + + Ok(id) + } + + async fn get( + &self, + thread_id: &str, + checkpoint_id: Option<&str>, + ) -> TaResult>> { + let config = self.config.clone(); + let thread_id = thread_id.to_string(); + let checkpoint_id = checkpoint_id.map(str::to_string); + + let record_json: Option = tokio::task::spawn_blocking(move || { + with_connection(&config, |conn| { + init_run_ledger_schema(conn)?; + // Latest-wins, matching the reference FileCheckpointer: scan in + // reverse insertion order and take the first match. + let row = match checkpoint_id { + Some(ref id) => conn + .query_row( + "SELECT record_json FROM graph_checkpoints \ + WHERE thread_id = ?1 AND checkpoint_id = ?2 \ + ORDER BY seq DESC LIMIT 1", + rusqlite::params![thread_id, id], + |r| r.get::<_, String>(0), + ) + .ok(), + None => conn + .query_row( + "SELECT record_json FROM graph_checkpoints \ + WHERE thread_id = ?1 ORDER BY seq DESC LIMIT 1", + rusqlite::params![thread_id], + |r| r.get::<_, String>(0), + ) + .ok(), + }; + Ok(row) + }) + .map_err(|e| db_err("get", e)) + }) + .await + .map_err(|e| db_err("get join", e))??; + + match record_json { + Some(json) => { + let checkpoint: Checkpoint = + serde_json::from_str(&json).map_err(|e| db_err("decode record", e))?; + Ok(Some(checkpoint)) + } + None => Ok(None), + } + } + + async fn list(&self, thread_id: &str) -> TaResult> { + let config = self.config.clone(); + let thread_id = thread_id.to_string(); + + let records: Vec = tokio::task::spawn_blocking(move || { + with_connection(&config, |conn| { + init_run_ledger_schema(conn)?; + let mut stmt = conn.prepare( + "SELECT record_json FROM graph_checkpoints \ + WHERE thread_id = ?1 ORDER BY seq ASC", + )?; + let rows = stmt + .query_map(rusqlite::params![thread_id], |r| r.get::<_, String>(0))? + .collect::, _>>()?; + Ok(rows) + }) + .map_err(|e| db_err("list", e)) + }) + .await + .map_err(|e| db_err("list join", e))??; + + records + .into_iter() + .map(|json| { + serde_json::from_str::>(&json) + .map(|c| c.to_metadata()) + .map_err(|e| db_err("decode record", e)) + }) + .collect() + } + + async fn list_threads(&self) -> TaResult> { + let config = self.config.clone(); + tokio::task::spawn_blocking(move || { + with_connection(&config, |conn| { + init_run_ledger_schema(conn)?; + let mut stmt = conn.prepare( + "SELECT DISTINCT thread_id FROM graph_checkpoints ORDER BY thread_id", + )?; + let rows = stmt + .query_map([], |r| r.get::<_, String>(0))? + .collect::, _>>()?; + Ok(rows) + }) + .map_err(|e| db_err("list_threads", e)) + }) + .await + .map_err(|e| db_err("list_threads join", e))? + } + + async fn delete_thread(&self, thread_id: &str) -> TaResult<()> { + let config = self.config.clone(); + let thread_id = thread_id.to_string(); + tokio::task::spawn_blocking(move || { + with_connection(&config, |conn| { + init_run_ledger_schema(conn)?; + conn.execute( + "DELETE FROM graph_checkpoints WHERE thread_id = ?1", + rusqlite::params![thread_id], + )?; + Ok(()) + }) + .map_err(|e| db_err("delete_thread", e)) + }) + .await + .map_err(|e| db_err("delete_thread join", e))? + } + + async fn delete_checkpoints(&self, thread_id: &str, ids: &[String]) -> TaResult { + if ids.is_empty() { + return Ok(0); + } + let config = self.config.clone(); + let thread_id = thread_id.to_string(); + let ids = ids.to_vec(); + tokio::task::spawn_blocking(move || { + with_connection(&config, |conn| { + init_run_ledger_schema(conn)?; + let mut removed = 0usize; + // Bind each id individually — keeps the statement simple and avoids + // SQLite's variadic-`IN` parameter packing. + let mut stmt = conn.prepare( + "DELETE FROM graph_checkpoints WHERE thread_id = ?1 AND checkpoint_id = ?2", + )?; + for id in &ids { + removed += stmt.execute(rusqlite::params![thread_id, id])?; + } + Ok(removed) + }) + .map_err(|e| db_err("delete_checkpoints", e)) + }) + .await + .map_err(|e| db_err("delete_checkpoints join", e))? + } +} diff --git a/src/openhuman/tinyagents/convert.rs b/src/openhuman/tinyagents/convert.rs new file mode 100644 index 000000000..a84e322a4 --- /dev/null +++ b/src/openhuman/tinyagents/convert.rs @@ -0,0 +1,494 @@ +//! Conversions between openhuman's flat [`ChatMessage`]/[`ToolSpec`]/[`ToolCall`] +//! wire types and the `tinyagents` harness' rich [`Message`]/[`ToolSchema`]/ +//! [`TaToolCall`] equivalents (issue #4249). +//! +//! The two sides model the same concepts with different shapes: +//! +//! - openhuman `ChatMessage` is `{ role: String, content: String }` — tool +//! calls and tool-result correlation ids are not first-class fields; the +//! legacy loop threads them through provider-native encoding instead. +//! - `tinyagents::harness::message::Message` is a typed enum +//! (`System`/`User`/`Assistant`/`Tool`) whose `Assistant` arm carries +//! structured `tool_calls` and whose `Tool` arm carries a `tool_call_id`. +//! +//! These helpers bridge the seed history into the harness and the harness' +//! resulting transcript back out, so a turn can run on the `tinyagents` +//! agent-loop while callers keep speaking openhuman's `ChatMessage` vocabulary. + +use tinyagents::harness::message::{ + AssistantMessage, ContentBlock, Message, SystemMessage, ToolMessage, UserMessage, +}; +use tinyagents::harness::tool::{ToolCall as TaToolCall, ToolSchema}; + +use crate::openhuman::inference::provider::{ChatMessage, ConversationMessage, ToolResultMessage}; +use crate::openhuman::tools::ToolSpec; + +/// Convert one openhuman [`ChatMessage`] into a harness [`Message`]. +/// +/// Role strings map onto the typed arms. A seeded **native** tool round is +/// serialized by [`NativeToolDispatcher::to_provider_messages`] as a +/// `{ "content", "tool_calls" }` assistant envelope followed by +/// `{ "tool_call_id", "content" }` tool envelopes; we unwrap those back into the +/// structured [`AssistantMessage::tool_calls`] / [`ToolMessage::tool_call_id`] +/// the harness needs. Without this, the seeded assistant loses its tool calls +/// while the following tool rows survive, so the harness re-sends orphan `tool` +/// messages and native providers reject the request (`assistant message with +/// 'tool_calls' must be followed by tool messages`). A plain assistant/tool +/// message that isn't an envelope maps straight through as text. +pub(super) fn chat_message_to_message(msg: &ChatMessage) -> Message { + let text = msg.content.clone(); + match msg.role.as_str() { + "system" => Message::System(SystemMessage { + content: vec![ContentBlock::Text(text)], + }), + "assistant" => { + if let Some((inner, tool_calls)) = parse_native_assistant_envelope(&text) { + Message::Assistant(AssistantMessage { + id: msg.id.clone(), + content: vec![ContentBlock::Text(inner)], + tool_calls, + usage: None, + }) + } else { + Message::Assistant(AssistantMessage { + id: msg.id.clone(), + content: vec![ContentBlock::Text(text)], + tool_calls: Vec::new(), + usage: None, + }) + } + } + "tool" => { + // Prefer the envelope's `tool_call_id` (the native seed shape); fall + // back to the message id, then an empty id for a bare tool message. + let (tool_call_id, content) = parse_native_tool_envelope(&text) + .unwrap_or_else(|| (msg.id.clone().unwrap_or_default(), text.clone())); + Message::Tool(ToolMessage { + tool_call_id, + content: vec![ContentBlock::Text(content)], + }) + } + // "user" and any unrecognized role default to a user turn — the safest + // mapping for a free-form inbound message. + _ => Message::User(UserMessage { + content: vec![ContentBlock::Text(text)], + }), + } +} + +/// Parse a native assistant tool-call envelope (`{ "content", "tool_calls" }`, as +/// [`NativeToolDispatcher::to_provider_messages`] emits) back into its inner +/// visible text and structured [`TaToolCall`]s. Returns `None` when `text` is not +/// such an envelope (plain assistant prose), so the caller can fall back to text. +fn parse_native_assistant_envelope(text: &str) -> Option<(String, Vec)> { + let value: serde_json::Value = serde_json::from_str(text).ok()?; + let obj = value.as_object()?; + let calls_val = obj.get("tool_calls")?; + // Require a non-empty, parseable tool-call array so ordinary JSON-looking + // assistant prose isn't misread as a tool round. + if !calls_val.as_array().is_some_and(|a| !a.is_empty()) { + return None; + } + let oh_calls: Vec = + serde_json::from_value(calls_val.clone()).ok()?; + if oh_calls.is_empty() { + return None; + } + let inner = obj + .get("content") + .and_then(|c| c.as_str()) + .unwrap_or_default() + .to_string(); + Some((inner, oh_calls.iter().map(oh_call_to_ta_call).collect())) +} + +/// Parse a native tool-result envelope (`{ "tool_call_id", "content" }`) back into +/// its correlation id and payload. Returns `None` for a bare tool message. +fn parse_native_tool_envelope(text: &str) -> Option<(String, String)> { + let value: serde_json::Value = serde_json::from_str(text).ok()?; + let obj = value.as_object()?; + let id = obj.get("tool_call_id")?.as_str()?.to_string(); + let content = obj + .get("content") + .and_then(|c| c.as_str()) + .unwrap_or_default() + .to_string(); + Some((id, content)) +} + +/// Inverse of [`ta_call_to_oh_call`]: rebuild a harness [`TaToolCall`] from an +/// openhuman [`ToolCall`] (whose `arguments` is a serialized JSON string). +fn oh_call_to_ta_call(oh: &crate::openhuman::inference::provider::ToolCall) -> TaToolCall { + TaToolCall { + id: oh.id.clone(), + name: oh.name.clone(), + arguments: serde_json::from_str(&oh.arguments).unwrap_or(serde_json::Value::Null), + } +} + +/// Convert a seed history into the harness `input` transcript. +pub(super) fn history_to_messages(history: &[ChatMessage]) -> Vec { + history.iter().map(chat_message_to_message).collect() +} + +/// Convert a harness [`Message`] back into an openhuman [`ChatMessage`]. +/// +/// Assistant tool calls are flattened to their text (the loop already executed +/// them and appended `Tool` result messages), and a tool message preserves its +/// correlation id on [`ChatMessage::id`] so downstream persistence keeps it. +pub(super) fn message_to_chat_message(msg: &Message) -> ChatMessage { + match msg { + Message::System(_) => ChatMessage::system(msg.text()), + Message::User(_) => ChatMessage::user(msg.text()), + Message::Assistant(_) => ChatMessage::assistant(msg.text()), + Message::Tool(t) => { + let mut cm = ChatMessage::tool(msg.text()); + cm.id = Some(t.tool_call_id.clone()); + cm + } + } +} + +/// Convert a harness transcript back into openhuman history. +pub(super) fn messages_to_history(messages: &[Message]) -> Vec { + messages.iter().map(message_to_chat_message).collect() +} + +/// Convert one harness [`Message`] into a [`ChatMessage`] for a **native** +/// tool-calling provider request, preserving the structure the provider needs to +/// round-trip a tool round: an assistant turn that made tool calls is encoded as +/// the `{ "content", "tool_calls" }` JSON envelope (matching the dispatcher's +/// native `to_provider_messages`), and a tool result as `{ "tool_call_id", +/// "content" }`. Without this the provider sees an assistant with no `tool_calls` +/// followed by an orphan tool message and drops the round — breaking multi-turn +/// native tool calling (e.g. the orchestrator's `spawn_parallel_agents` → +/// synthesis hop). +pub(super) fn message_to_native_chat_message(msg: &Message) -> ChatMessage { + match msg { + Message::System(_) => ChatMessage::system(msg.text()), + Message::User(_) => ChatMessage::user(msg.text()), + Message::Assistant(a) if !a.tool_calls.is_empty() => { + let tool_calls: Vec<_> = a.tool_calls.iter().map(ta_call_to_oh_call).collect(); + let payload = serde_json::json!({ + "content": msg.text(), + "tool_calls": tool_calls, + }); + ChatMessage::assistant(payload.to_string()) + } + Message::Assistant(_) => ChatMessage::assistant(msg.text()), + Message::Tool(t) => { + let payload = serde_json::json!({ + "tool_call_id": t.tool_call_id, + "content": msg.text(), + }); + let mut cm = ChatMessage::tool(payload.to_string()); + cm.id = Some(t.tool_call_id.clone()); + cm + } + } +} + +/// Convert a harness transcript into the **typed** [`ConversationMessage`] shape +/// the chat session persists, preserving assistant tool-call structure +/// (`AssistantToolCalls`) and tool results (`ToolResults`) — unlike +/// [`messages_to_history`], which flattens tool calls to text. +/// +/// Consecutive `Tool` messages are coalesced into one `ToolResults` batch (the +/// shape a single assistant tool-call round produces), matching the legacy +/// `turn_engine_adapter` persistence. +pub(super) fn messages_to_conversation(messages: &[Message]) -> Vec { + let mut out: Vec = Vec::new(); + let mut pending: Vec = Vec::new(); + + fn flush(out: &mut Vec, pending: &mut Vec) { + if !pending.is_empty() { + out.push(ConversationMessage::ToolResults(std::mem::take(pending))); + } + } + + for msg in messages { + match msg { + Message::Tool(t) => { + pending.push(ToolResultMessage { + tool_call_id: t.tool_call_id.clone(), + content: msg.text(), + }); + } + Message::System(_) => { + flush(&mut out, &mut pending); + out.push(ConversationMessage::Chat(ChatMessage::system(msg.text()))); + } + Message::User(_) => { + flush(&mut out, &mut pending); + out.push(ConversationMessage::Chat(ChatMessage::user(msg.text()))); + } + Message::Assistant(a) => { + flush(&mut out, &mut pending); + if a.tool_calls.is_empty() { + out.push(ConversationMessage::Chat(ChatMessage::assistant( + msg.text(), + ))); + } else { + let text = msg.text(); + out.push(ConversationMessage::AssistantToolCalls { + text: (!text.is_empty()).then_some(text), + tool_calls: a.tool_calls.iter().map(ta_call_to_oh_call).collect(), + reasoning_content: None, + extra_metadata: None, + }); + } + } + } + } + flush(&mut out, &mut pending); + out +} + +/// The suffix of `messages` produced *after* the most recent user turn — i.e. +/// the assistant/tool messages a single turn appended. Robust to front-trimming +/// middleware (which drops old messages but keeps the current user turn). +pub(super) fn messages_since_last_user(messages: &[Message]) -> &[Message] { + let start = messages + .iter() + .rposition(|m| matches!(m, Message::User(_))) + .map(|i| i + 1) + .unwrap_or(0); + &messages[start..] +} + +/// Convert a harness transcript into openhuman [`ChatMessage`]s for a provider +/// that does **not** support native tool calls (text/prompt-guided mode). +/// +/// Consecutive `Tool` result messages are coalesced into a single +/// `[Tool results]` user turn — the shape prompt-guided models are taught to +/// read — instead of native `tool`-role messages they wouldn't understand. +/// Other messages convert as usual (assistant tool calls already rode the +/// visible text in this mode). +pub(super) fn messages_to_text_mode_chat(messages: &[Message]) -> Vec { + let mut out: Vec = Vec::new(); + let mut pending: Vec = Vec::new(); + + fn flush(out: &mut Vec, pending: &mut Vec) { + if !pending.is_empty() { + out.push(ChatMessage::user(format!( + "[Tool results]\n{}", + std::mem::take(pending).join("\n") + ))); + } + } + + for msg in messages { + match msg { + Message::Tool(_) => pending.push(msg.text()), + _ => { + flush(&mut out, &mut pending); + out.push(message_to_chat_message(msg)); + } + } + } + flush(&mut out, &mut pending); + out +} + +/// Convert an openhuman [`ToolSpec`] into a harness [`ToolSchema`]. +pub(super) fn spec_to_schema(spec: &ToolSpec) -> ToolSchema { + // `ToolSchema::new` sets the model-visible tool-call format to the JSON + // default (tinyagents 1.0), which is what openhuman advertises. + ToolSchema::new( + spec.name.clone(), + spec.description.clone(), + spec.parameters.clone(), + ) +} + +/// Convert a harness [`TaToolCall`] into an openhuman [`ToolCall`]. +/// +/// The harness models arguments as parsed JSON; openhuman carries them as the +/// raw JSON string the provider emitted, so we re-serialize. +pub(super) fn ta_call_to_oh_call( + call: &TaToolCall, +) -> crate::openhuman::inference::provider::ToolCall { + crate::openhuman::inference::provider::ToolCall { + id: call.id.clone(), + name: call.name.clone(), + arguments: call.arguments.to_string(), + extra_content: None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn seeded_native_tool_round_recovers_structure_and_round_trips() { + use crate::openhuman::inference::provider::ToolCall as OhToolCall; + // The native dispatcher seeds an assistant tool round as a + // {content, tool_calls} envelope followed by {tool_call_id, content} rows. + let oh_call = OhToolCall { + id: "call-1".into(), + name: "echo".into(), + arguments: r#"{"msg":"hi"}"#.into(), + extra_content: None, + }; + let assistant_cm = ChatMessage::assistant( + serde_json::json!({ "content": "calling echo", "tool_calls": [oh_call] }).to_string(), + ); + let tool_cm = ChatMessage::tool( + serde_json::json!({ "tool_call_id": "call-1", "content": "echoed:hi" }).to_string(), + ); + + // Inbound: the envelopes are recovered into structured harness messages. + let a = chat_message_to_message(&assistant_cm); + let Message::Assistant(am) = &a else { + panic!("expected Assistant, got {a:?}"); + }; + assert_eq!(am.tool_calls.len(), 1); + assert_eq!(am.tool_calls[0].id, "call-1"); + assert_eq!(am.tool_calls[0].name, "echo"); + assert_eq!( + am.tool_calls[0].arguments, + serde_json::json!({ "msg": "hi" }) + ); + assert_eq!(a.text(), "calling echo"); + + let t = chat_message_to_message(&tool_cm); + let Message::Tool(tm) = &t else { + panic!("expected Tool, got {t:?}"); + }; + assert_eq!(tm.tool_call_id, "call-1"); + assert_eq!(t.text(), "echoed:hi"); + + // Outbound: re-serialized to a well-formed native tool round (assistant + // carries structured tool_calls, the tool row carries the matching id). + let a_native = message_to_native_chat_message(&a); + assert_eq!(a_native.role, "assistant"); + let av: serde_json::Value = serde_json::from_str(&a_native.content).unwrap(); + assert_eq!(av["tool_calls"][0]["id"], "call-1"); + assert_eq!(av["content"], "calling echo"); + + let t_native = message_to_native_chat_message(&t); + assert_eq!(t_native.role, "tool"); + let tv: serde_json::Value = serde_json::from_str(&t_native.content).unwrap(); + assert_eq!(tv["tool_call_id"], "call-1"); + assert_eq!(tv["content"], "echoed:hi"); + } + + #[test] + fn plain_assistant_prose_is_not_misread_as_a_tool_round() { + let a = chat_message_to_message(&ChatMessage::assistant("just a normal reply")); + let Message::Assistant(am) = &a else { + panic!("expected Assistant, got {a:?}"); + }; + assert!(am.tool_calls.is_empty()); + assert_eq!(a.text(), "just a normal reply"); + } + + #[test] + fn roles_round_trip_through_the_bridge() { + let history = vec![ + ChatMessage::system("you are helpful"), + ChatMessage::user("hello"), + ChatMessage::assistant("hi there"), + ]; + let messages = history_to_messages(&history); + assert!(matches!(messages[0], Message::System(_))); + assert!(matches!(messages[1], Message::User(_))); + assert!(matches!(messages[2], Message::Assistant(_))); + + let back = messages_to_history(&messages); + assert_eq!(back.len(), 3); + assert_eq!(back[0].role, "system"); + assert_eq!(back[1].content, "hello"); + assert_eq!(back[2].role, "assistant"); + } + + #[test] + fn tool_message_preserves_correlation_id() { + let messages = vec![Message::Tool(ToolMessage { + tool_call_id: "call-7".into(), + content: vec![ContentBlock::Text("done".into())], + })]; + let back = messages_to_history(&messages); + assert_eq!(back[0].role, "tool"); + assert_eq!(back[0].content, "done"); + assert_eq!(back[0].id.as_deref(), Some("call-7")); + } + + #[test] + fn conversation_preserves_tool_call_structure() { + let messages = vec![ + Message::User(UserMessage { + content: vec![ContentBlock::Text("do it".into())], + }), + Message::Assistant(AssistantMessage { + id: None, + content: vec![ContentBlock::Text("calling".into())], + tool_calls: vec![TaToolCall { + id: "c1".into(), + name: "echo".into(), + arguments: serde_json::json!({"msg": "hi"}), + }], + usage: None, + }), + Message::Tool(ToolMessage { + tool_call_id: "c1".into(), + content: vec![ContentBlock::Text("echoed:hi".into())], + }), + Message::Assistant(AssistantMessage { + id: None, + content: vec![ContentBlock::Text("all done".into())], + tool_calls: vec![], + usage: None, + }), + ]; + + // Only the suffix after the last user turn is persisted. + let suffix = messages_since_last_user(&messages); + let convo = messages_to_conversation(suffix); + assert_eq!(convo.len(), 3); + match &convo[0] { + ConversationMessage::AssistantToolCalls { tool_calls, .. } => { + assert_eq!(tool_calls[0].name, "echo"); + assert_eq!(tool_calls[0].id, "c1"); + } + other => panic!("expected AssistantToolCalls, got {other:?}"), + } + match &convo[1] { + ConversationMessage::ToolResults(results) => { + assert_eq!(results[0].tool_call_id, "c1"); + assert_eq!(results[0].content, "echoed:hi"); + } + other => panic!("expected ToolResults, got {other:?}"), + } + match &convo[2] { + ConversationMessage::Chat(c) => { + assert_eq!(c.role, "assistant"); + assert_eq!(c.content, "all done"); + } + other => panic!("expected Chat, got {other:?}"), + } + } + + #[test] + fn spec_and_tool_call_convert() { + let spec = ToolSpec { + name: "echo".into(), + description: "echoes".into(), + parameters: serde_json::json!({"type": "object"}), + }; + let schema = spec_to_schema(&spec); + assert_eq!(schema.name, "echo"); + assert_eq!(schema.parameters, serde_json::json!({"type": "object"})); + + let ta = TaToolCall { + id: "c1".into(), + name: "echo".into(), + arguments: serde_json::json!({"msg": "hi"}), + }; + let oh = ta_call_to_oh_call(&ta); + assert_eq!(oh.id, "c1"); + assert_eq!(oh.name, "echo"); + assert_eq!(oh.arguments, r#"{"msg":"hi"}"#); + } +} diff --git a/src/openhuman/tinyagents/delegation.rs b/src/openhuman/tinyagents/delegation.rs new file mode 100644 index 000000000..06a33c2bb --- /dev/null +++ b/src/openhuman/tinyagents/delegation.rs @@ -0,0 +1,444 @@ +//! Multi-stage sub-agent delegation expressed as a `tinyagents` orchestration +//! graph (issue #4249, #27/#28). +//! +//! Where [`run_turn_via_tinyagents_shared`](super::run_turn_via_tinyagents_shared) +//! drives *one* agent turn, this module composes *several* sub-agent stages into a +//! durable, resumable state machine — the SDK-native replacement for ad-hoc +//! `run_subagent` chaining: +//! +//! ```text +//! plan ─▶ execute ─▶ review ──approved/maxed──▶ finalize ─▶ END +//! ▲ │ +//! └─────revise────────┘ +//! ``` +//! +//! Every feature the graph layer offers is exercised here: +//! - **conditional routing** — `review` returns a [`Command`] that routes to +//! `execute` (revise) or `finalize` (done) based on the stage result; +//! - **recursion bounds** — a [`RecursionPolicy`] caps the `execute ⇄ review` +//! revision loop as a backstop to the in-state `revisions` counter; +//! - **durable checkpoint/resume** — an optional [`Checkpointer`] persists the +//! typed [`DelegationState`] at every super-step boundary (`run_with_thread`), +//! so a crashed or paused run resumes from its last node; +//! - **cooperative cancellation** — a [`CancellationToken`] short-circuits the +//! pipeline to `finalize` at the next node boundary. +//! +//! The per-stage worker is injected ([`run_delegation`]) so the orchestration +//! mechanics are unit tested with a deterministic mock; production passes a +//! closure that runs each stage through `run_subagent` / the agent harness. + +use std::future::Future; +use std::sync::Arc; + +use serde::{Deserialize, Serialize}; +use tinyagents::graph::checkpoint::Checkpointer; +use tinyagents::graph::recursion::RecursionPolicy; +use tinyagents::graph::ClosureStateReducer; +use tinyagents::graph::{Command, GraphBuilder, NodeContext, NodeResult, END}; +use tinyagents::CancellationToken; + +/// Which stage a delegation node is asking the injected worker to run. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DelegationStage { + /// Produce a plan for the task. + Plan, + /// Execute the current plan (re-run on revision). + Execute, + /// Review the latest execution; may approve or request a revision. + Review, +} + +/// What an injected stage worker returns. +#[derive(Debug, Clone)] +pub struct DelegationStageOutput { + /// The stage's textual output (plan text, execution result, or review note). + pub text: String, + /// Only meaningful for [`DelegationStage::Review`]: `true` approves the + /// execution and ends the loop; `false` requests another revision. + pub approved: bool, +} + +impl DelegationStageOutput { + /// A plain non-review stage output (the `approved` flag is unused). + pub fn done(text: impl Into) -> Self { + Self { + text: text.into(), + approved: true, + } + } +} + +/// Typed working state threaded through (and checkpointed across) the delegation +/// graph. Serde-serializable so a [`Checkpointer`] can persist and restore it. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct DelegationState { + /// The plan produced by the `plan` stage. + pub plan: Option, + /// One entry per execution pass (the first plus each revision). + pub executions: Vec, + /// One entry per review pass. + pub reviews: Vec, + /// Number of revisions the reviewer requested (loops back to `execute`). + pub revisions: usize, + /// Set once the reviewer approves or the revision cap is hit. + pub approved: bool, + /// The final synthesized output (set by `finalize`). + pub final_output: Option, + /// Set when the run short-circuited because its token was cancelled. + pub cancelled: bool, +} + +/// Reducer updates emitted by the delegation nodes. +enum DelegationUpdate { + Plan(String), + Execution(String), + Review { note: String, approved: bool }, + Final(String), + Cancelled, +} + +/// Configuration for a delegation run. +pub struct DelegationConfig { + /// Upper bound on reviewer-requested revisions before forcing `finalize`. + pub max_revisions: usize, + /// Optional durable checkpointer (e.g. a `FileCheckpointer`). When set with a + /// `thread_id`, the run persists its state at every super-step boundary. + pub checkpointer: Option>>, + /// Thread id for checkpoint keying; required for the checkpointer to persist. + pub thread_id: Option, + /// Cooperative cancellation; checked at each node boundary. + pub cancel: CancellationToken, +} + +impl Default for DelegationConfig { + fn default() -> Self { + Self { + max_revisions: 2, + checkpointer: None, + thread_id: None, + cancel: CancellationToken::new(), + } + } +} + +/// Run the plan→execute⇄review→finalize delegation graph, invoking `run_stage` +/// for each stage. Returns the final [`DelegationState`]. +/// +/// `run_stage` is the seam to the agent harness: production passes a closure that +/// dispatches each [`DelegationStage`] to `run_subagent`; tests pass a mock. +pub async fn run_delegation( + config: DelegationConfig, + run_stage: F, +) -> Result +where + F: Fn(DelegationStage, DelegationState) -> Fut + Clone + Send + Sync + 'static, + Fut: Future> + Send + 'static, +{ + let max_revisions = config.max_revisions; + let cancel = config.cancel.clone(); + + let mut builder = GraphBuilder::::new().set_reducer( + ClosureStateReducer::new(|mut s: DelegationState, u: DelegationUpdate| { + match u { + DelegationUpdate::Plan(p) => s.plan = Some(p), + DelegationUpdate::Execution(e) => s.executions.push(e), + DelegationUpdate::Review { note, approved } => { + s.reviews.push(note); + s.approved = approved; + if !approved { + s.revisions += 1; + } + } + DelegationUpdate::Final(f) => s.final_output = Some(f), + DelegationUpdate::Cancelled => s.cancelled = true, + } + Ok(s) + }), + ); + + // plan: produce the plan, then route to execute (or finalize if cancelled). + let run_plan = run_stage.clone(); + let cancel_plan = cancel.clone(); + builder = builder.add_node("plan", move |s: DelegationState, _c: NodeContext| { + let run_plan = run_plan.clone(); + let cancel = cancel_plan.clone(); + async move { + if cancel.is_cancelled() { + return Ok(NodeResult::Command( + Command::default() + .with_update(DelegationUpdate::Cancelled) + .with_goto(["finalize"]), + )); + } + let out = run_plan(DelegationStage::Plan, s) + .await + .map_err(to_node_err)?; + Ok(NodeResult::Command( + Command::default() + .with_update(DelegationUpdate::Plan(out.text)) + .with_goto(["execute"]), + )) + } + }); + + // execute: run the plan; route to review. + let run_exec = run_stage.clone(); + let cancel_exec = cancel.clone(); + builder = builder.add_node("execute", move |s: DelegationState, _c: NodeContext| { + let run_exec = run_exec.clone(); + let cancel = cancel_exec.clone(); + async move { + if cancel.is_cancelled() { + return Ok(NodeResult::Command( + Command::default() + .with_update(DelegationUpdate::Cancelled) + .with_goto(["finalize"]), + )); + } + let out = run_exec(DelegationStage::Execute, s) + .await + .map_err(to_node_err)?; + Ok(NodeResult::Command( + Command::default() + .with_update(DelegationUpdate::Execution(out.text)) + .with_goto(["review"]), + )) + } + }); + + // review: approve (→ finalize) or request a revision (→ execute), bounded by + // `max_revisions` so a never-approving reviewer still terminates. + let run_review = run_stage.clone(); + let cancel_review = cancel.clone(); + builder = builder.add_node("review", move |s: DelegationState, _c: NodeContext| { + let run_review = run_review.clone(); + let cancel = cancel_review.clone(); + async move { + if cancel.is_cancelled() { + return Ok(NodeResult::Command( + Command::default() + .with_update(DelegationUpdate::Cancelled) + .with_goto(["finalize"]), + )); + } + let revisions = s.revisions; + let out = run_review(DelegationStage::Review, s) + .await + .map_err(to_node_err)?; + // Approve when the reviewer is satisfied OR the revision budget is spent. + let approved = out.approved || revisions >= max_revisions; + let next = if approved { "finalize" } else { "execute" }; + Ok(NodeResult::Command( + Command::default() + .with_update(DelegationUpdate::Review { + note: out.text, + approved, + }) + .with_goto([next]), + )) + } + }); + + // finalize: synthesize the final output from the accumulated state, then end. + builder = builder.add_node( + "finalize", + move |s: DelegationState, _c: NodeContext| async move { + let summary = s + .executions + .last() + .cloned() + .unwrap_or_else(|| "".to_string()); + let final_text = if s.cancelled { + format!("cancelled after {} execution(s)", s.executions.len()) + } else { + summary + }; + Ok(NodeResult::Command( + Command::default() + .with_update(DelegationUpdate::Final(final_text)) + .with_goto([END]), + )) + }, + ); + + builder = builder + .set_entry("plan") + .mark_command_routing("plan") + .mark_command_routing("execute") + .mark_command_routing("review") + .mark_command_routing("finalize"); + + let mut graph = builder + .compile() + .map_err(|e| format!("delegation graph compile failed: {e}"))? + .with_event_sink(Arc::new(super::observability::GraphTracingSink::new( + "delegation:graph", + ))) + // Bound the execute⇄review loop as a backstop to the in-state counter: + // each of execute/review may be visited at most max_revisions + 1 times. + .with_recursion_policy(RecursionPolicy { + max_visits_per_node: Some(max_revisions + 2), + max_total_steps: (max_revisions + 1) * 4 + 8, + ..RecursionPolicy::default() + }); + + if let Some(cp) = config.checkpointer { + graph = graph.with_checkpointer(cp); + } + + tracing::info!( + max_revisions, + durable = config.thread_id.is_some(), + "[delegation] running sub-agent delegation graph" + ); + + let execution = match config.thread_id { + Some(thread_id) => { + graph + .run_with_thread(thread_id, DelegationState::default()) + .await + } + None => graph.run(DelegationState::default()).await, + } + .map_err(|e| format!("delegation graph run failed: {e}"))?; + + Ok(execution.state) +} + +/// Map an injected-stage error string into a graph node error. +fn to_node_err(e: String) -> tinyagents::TinyAgentsError { + tinyagents::TinyAgentsError::Model(e) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Mutex; + + /// A reviewer that rejects the first `reject_first` executions, then approves, + /// driving the execute⇄review revision loop. + fn flow_runner( + reject_first: usize, + ) -> impl Fn( + DelegationStage, + DelegationState, + ) -> std::pin::Pin< + Box> + Send>, + > + Clone + + Send + + Sync + + 'static { + let reviews = Arc::new(AtomicUsize::new(0)); + move |stage, _state| { + let reviews = reviews.clone(); + Box::pin(async move { + match stage { + DelegationStage::Plan => Ok(DelegationStageOutput::done("PLAN")), + DelegationStage::Execute => Ok(DelegationStageOutput::done("EXEC")), + DelegationStage::Review => { + let n = reviews.fetch_add(1, Ordering::SeqCst); + Ok(DelegationStageOutput { + text: format!("review-{n}"), + approved: n >= reject_first, + }) + } + } + }) + } + } + + #[tokio::test] + async fn approves_first_pass_no_revision() { + let state = run_delegation(DelegationConfig::default(), flow_runner(0)) + .await + .expect("runs"); + assert_eq!(state.plan.as_deref(), Some("PLAN")); + assert_eq!(state.executions.len(), 1, "one execution, no revision"); + assert_eq!(state.reviews.len(), 1); + assert_eq!(state.revisions, 0); + assert!(state.approved); + assert_eq!(state.final_output.as_deref(), Some("EXEC")); + } + + #[tokio::test] + async fn revises_then_approves() { + // Reject the first review → one revision (a second execute+review). + let state = run_delegation(DelegationConfig::default(), flow_runner(1)) + .await + .expect("runs"); + assert_eq!(state.executions.len(), 2, "initial + one revised execution"); + assert_eq!(state.reviews.len(), 2); + assert_eq!(state.revisions, 1); + assert!(state.approved); + } + + #[tokio::test] + async fn revision_budget_caps_a_never_approving_reviewer() { + // Reviewer never approves on its own; the max_revisions cap forces finalize. + let config = DelegationConfig { + max_revisions: 2, + ..DelegationConfig::default() + }; + let state = run_delegation(config, flow_runner(999)) + .await + .expect("runs"); + // revisions counted: 1st review (rev 1), 2nd review (rev 2), 3rd review + // hits revisions>=2 → forced approve. So 3 executions, 3 reviews. + assert_eq!(state.revisions, 2, "stops at the revision budget"); + assert!(state.approved, "forced-approved at the cap"); + assert_eq!(state.executions.len(), 3); + } + + #[tokio::test] + async fn cancellation_short_circuits_to_finalize() { + let cancel = CancellationToken::new(); + cancel.cancel(); + let ran = Arc::new(Mutex::new(Vec::::new())); + let ran2 = ran.clone(); + let runner = move |stage: DelegationStage, _s: DelegationState| { + let ran = ran2.clone(); + Box::pin(async move { + ran.lock().unwrap().push(stage); + Ok::<_, String>(DelegationStageOutput::done("X")) + }) as std::pin::Pin + Send>> + }; + let config = DelegationConfig { + cancel, + ..DelegationConfig::default() + }; + let state = run_delegation(config, runner).await.expect("runs"); + assert!(state.cancelled, "state flagged cancelled"); + assert!(state.final_output.is_some()); + assert!( + ran.lock().unwrap().is_empty(), + "no stage worker ran once cancelled at the plan boundary" + ); + } + + #[tokio::test] + async fn durable_checkpointer_persists_thread_state() { + let dir = tempfile::tempdir().unwrap(); + let cp: Arc> = Arc::new( + tinyagents::graph::checkpoint::FileCheckpointer::new(dir.path()), + ); + let config = DelegationConfig { + checkpointer: Some(cp.clone()), + thread_id: Some("run-1".to_string()), + ..DelegationConfig::default() + }; + let state = run_delegation(config, flow_runner(1)).await.expect("runs"); + assert!(state.approved); + // The checkpointer recorded the run under its thread id. + let threads = cp.list_threads().await.expect("list threads"); + assert!( + threads.iter().any(|t| t == "run-1"), + "thread persisted, saw {threads:?}" + ); + let checkpoints = cp.list("run-1").await.expect("list checkpoints"); + assert!( + !checkpoints.is_empty(), + "at least one super-step boundary checkpoint persisted" + ); + } +} diff --git a/src/openhuman/tinyagents/middleware.rs b/src/openhuman/tinyagents/middleware.rs new file mode 100644 index 000000000..227b50e5a --- /dev/null +++ b/src/openhuman/tinyagents/middleware.rs @@ -0,0 +1,1119 @@ +//! openhuman context concerns expressed as tinyagents graph middlewares +//! (issue #4249). +//! +//! Historically these ran in the in-house engine's tool/prompt plumbing +//! (`agent_tool_exec`, `ContextManager`). The tinyagents turn path bypassed +//! them, so they were effectively dead on the live loop. Re-expressing them as +//! [`Middleware`] hooks restores the behaviour and makes the graph the single +//! place cross-cutting context concerns live: +//! +//! - [`CacheAlignMiddleware`] (`before_model`) — warn on volatile tokens in the +//! system prompt that would bust the provider KV-cache prefix. Warn-only. +//! - [`MicrocompactMiddleware`] (`before_model`) — clear the bodies of older +//! tool-result messages (keeping the N most recent) so a long tool-heavy +//! thread stays cheap without dropping chat history. +//! - [`ToolOutputMiddleware`] (`after_tool`) — apply the per-tool-result byte +//! cap and (optionally) the semantic payload summarizer to each tool result +//! as it returns, before it enters the transcript. +//! +//! [`TurnContextMiddleware`] bundles the config and installs whichever hooks are +//! enabled onto a harness. + +use std::collections::HashSet; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; + +use async_trait::async_trait; + +use tinyagents::error::Result as TaResult; +use tinyagents::harness::context::RunContext; +use tinyagents::harness::message::{ContentBlock, Message as TaMessage}; +use tinyagents::harness::middleware::{ + Middleware, MiddlewareToolOutcome, ToolHandler, ToolMiddleware, +}; +use tinyagents::harness::model::ModelRequest; +use tinyagents::harness::runtime::AgentHarness; +use tinyagents::harness::steering::{SteeringCommand, SteeringHandle}; +use tinyagents::harness::tool::{ToolCall as TaToolCall, ToolResult as TaToolResult}; + +use super::tools::UNKNOWN_TOOL_SENTINEL; +use crate::openhuman::agent::harness::payload_summarizer::PayloadSummarizer; +use crate::openhuman::approval::{ + redact_args, summarize_action, ApprovalGate, ExecutionOutcome, GateOutcome, +}; +use crate::openhuman::context::tool_result_budget::apply_tool_result_budget; +use crate::openhuman::context::CLEARED_PLACEHOLDER; +use crate::openhuman::tools::Tool; + +/// Default per-tool-result byte cap for the channel / sub-agent paths, which do +/// not carry a session `ContextManager` to source the configured budget from. +/// Mirrors the `ContextConfig::tool_result_budget_bytes` default (16 KiB). +pub const DEFAULT_TOOL_RESULT_BUDGET_BYTES: usize = 16 * 1024; + +/// Config bundle for the openhuman context middlewares installed on a turn. +/// +/// Cheap to clone (the summarizer is an `Arc`). An all-default value installs +/// nothing — [`install`](Self::install) is a no-op. +#[derive(Clone, Default)] +pub struct TurnContextMiddleware { + /// Per-tool-result byte cap. `0` disables the cap. + pub tool_result_budget_bytes: usize, + /// Optional semantic tool-output summarizer (progressive disclosure). + pub payload_summarizer: Option>, + /// Warn on volatile tokens in the system prompt (KV-cache diagnostic). + pub cache_align: bool, + /// Keep-recent count for microcompact tool-body clearing. `0` disables it. + pub microcompact_keep_recent: usize, + /// Whether the LLM summarization step (`ContextCompressionMiddleware`) may be + /// installed on this turn. `false` when `[context].enabled` or + /// `autocompact_enabled` is off, so a diagnostic/test opt-out doesn't spend + /// summarizer tokens or rewrite history. The deterministic hard-trim backstop + /// still installs regardless. Defaults to `true` (see [`defaults`](Self::defaults)). + pub autocompact_enabled: bool, + /// "Super context" first-turn context collection. `Some` installs the + /// [`SuperContextMiddleware`] graph node; `None` (the default, and every + /// non-chat path) skips it. Only the chat turn sets this — and only when its + /// gate (`should_run_super_context`) passes. + pub super_context: Option, +} + +/// Inputs the [`SuperContextMiddleware`] node needs to run its first-turn +/// read-only context-collection pass. +#[derive(Clone)] +pub struct SuperContextConfig { + /// The raw user ask, used as the context scout's query. + pub user_message: String, +} + +impl TurnContextMiddleware { + /// A sensible default for turn paths without a session `ContextManager` + /// (channel / sub-agent): cache-align warnings on and the default tool-result + /// byte cap, no summarizer or microcompact. + pub fn defaults() -> Self { + Self { + tool_result_budget_bytes: DEFAULT_TOOL_RESULT_BUDGET_BYTES, + payload_summarizer: None, + cache_align: true, + microcompact_keep_recent: 0, + autocompact_enabled: true, + super_context: None, + } + } + + /// `true` when no middleware would be installed. + pub fn is_empty(&self) -> bool { + self.tool_result_budget_bytes == 0 + && self.payload_summarizer.is_none() + && !self.cache_align + && self.microcompact_keep_recent == 0 + && self.super_context.is_none() + } + + /// Push the enabled middlewares onto `harness`. + /// + /// `before_model` hooks run in registration order, so cache-align (warn) and + /// microcompact (clear tool bodies) are installed **before** the caller's + /// summarization / trim middlewares — microcompact frees cheap tokens first, + /// then summarization/trim handle the rest. + pub fn install(self, harness: &mut AgentHarness<()>, tool_sets: &[Arc>>]) { + // Super context runs first: it prepares the read-only context bundle and + // folds it into the first model call's user message before any other + // before_model hook inspects the request. + if let Some(sc) = self.super_context { + harness.push_middleware(Arc::new(SuperContextMiddleware { + user_message: sc.user_message, + ran: AtomicBool::new(false), + })); + } + if self.cache_align { + harness.push_middleware(Arc::new(CacheAlignMiddleware)); + } + if self.microcompact_keep_recent > 0 { + harness.push_middleware(Arc::new(MicrocompactMiddleware { + keep_recent: self.microcompact_keep_recent, + })); + } + if self.tool_result_budget_bytes > 0 || self.payload_summarizer.is_some() { + harness.push_middleware(Arc::new(ToolOutputMiddleware { + budget_bytes: self.tool_result_budget_bytes, + payload_summarizer: self.payload_summarizer, + tool_sets: tool_sets.to_vec(), + })); + } + } +} + +/// `before_model` (first call only): "super context" — the graph node analogue +/// of the harness-driven first-turn context collection that used to run +/// imperatively in `session/turn/core.rs`. On the first model call it runs the +/// read-only `context_scout` sub-agent against the raw user ask, folds the +/// resulting `[context_bundle]` into the user message, and registers a +/// prepared-context source so a later `agent_prepare_context` call in the same +/// turn self-suppresses. +/// +/// Best-effort: any scout error leaves the turn to proceed with the +/// un-augmented message rather than blocking the user. Runs inside the parent +/// context scope the chat turn already installs (`with_parent_context`), which +/// the scout reads via `current_parent()`. +struct SuperContextMiddleware { + /// The raw user ask, used as the scout's query (not the enriched message). + user_message: String, + /// One-shot latch — `before_model` fires on every model call, but super + /// context is a first-turn, once-per-run pass. + ran: AtomicBool, +} + +#[async_trait] +impl Middleware<()> for SuperContextMiddleware { + fn name(&self) -> &str { + "super_context" + } + + async fn before_model( + &self, + _ctx: &mut RunContext<()>, + _state: &(), + request: &mut ModelRequest, + ) -> TaResult<()> { + if self.ran.swap(true, Ordering::SeqCst) { + return Ok(()); + } + let scout = crate::openhuman::agent_orchestration::tools::run_context_scout( + &self.user_message, + None, + ) + .await; + match scout { + Ok(result) if !result.is_error => { + let bundle = result.output(); + // Register the source live so `agent_prepare_context` (which reads + // `current_agent_context_prepared_sources()`) self-suppresses for + // the rest of the turn. Only on success — a failed scout must not + // block a legitimate retry. + crate::openhuman::agent::harness::push_agent_context_prepared_source( + crate::openhuman::agent::harness::AgentContextPreparedSource { + source: "super context preparation".to_string(), + has_enough_context: parse_context_bundle_has_enough_context(&bundle), + }, + ); + tracing::info!( + bundle_chars = bundle.chars().count(), + "[tinyagents::mw] super_context bundle collected — folding into user message" + ); + let block = format!( + "## Agent context status\n\nAgent context retrieval/preparation has already \ + run once for this turn in code via super context preparation. Do not call \ + `agent_prepare_context` again for general context preparation. Use the \ + prepared context below, and call only specific follow-up tools if a concrete \ + missing detail is required.\n\n\ + ## Prepared context (super context)\n\nThe following context was collected \ + up-front by a read-only context scout before this turn. Use it to ground your \ + response; do not call `agent_prepare_context` again for general preparation.\n\n\ + {bundle}\n\n---\n\n" + ); + prepend_text_to_last_user(&mut request.messages, block); + } + Ok(_) => { + tracing::warn!( + "[tinyagents::mw] super_context scout returned an error — proceeding without bundle" + ); + } + Err(err) => { + tracing::warn!( + %err, + "[tinyagents::mw] super_context collection failed — proceeding without bundle" + ); + } + } + Ok(()) + } +} + +/// Prepend a text block to the most recent user message, preserving its existing +/// content blocks (multimodal image blocks survive — the bundle rides in front +/// as a new leading text block). No-op if there is no user message. +fn prepend_text_to_last_user(messages: &mut [TaMessage], block: String) { + if let Some(TaMessage::User(m)) = messages + .iter_mut() + .rev() + .find(|m| matches!(m, TaMessage::User(_))) + { + m.content.insert(0, ContentBlock::Text(block)); + } +} + +/// Parse the `has_enough_context: true|false` marker line the context scout +/// emits inside its `[context_bundle]`. Mirrors the former core.rs helper so the +/// prepared-source record carries the same signal. Returns `None` when absent or +/// unparseable. +fn parse_context_bundle_has_enough_context(bundle: &str) -> Option { + const PREFIX: &str = "has_enough_context:"; + let line = bundle.lines().map(str::trim).find(|line| { + line.get(..PREFIX.len()) + .is_some_and(|prefix| prefix.eq_ignore_ascii_case(PREFIX)) + })?; + let value = line[PREFIX.len()..].trim(); + if value.eq_ignore_ascii_case("true") { + Some(true) + } else if value.eq_ignore_ascii_case("false") { + Some(false) + } else { + None + } +} + +/// `before_model`: flag volatile tokens (UUIDs, timestamps, JWTs, …) in the +/// system prompt that silently break the provider KV-cache prefix. Warn-only — +/// never mutates the request. The graph analogue of the former +/// `ContextManager::warn_if_cache_unstable`. +struct CacheAlignMiddleware; + +#[async_trait] +impl Middleware<()> for CacheAlignMiddleware { + fn name(&self) -> &str { + "cache_align" + } + + async fn before_model( + &self, + _ctx: &mut RunContext<()>, + _state: &(), + request: &mut ModelRequest, + ) -> TaResult<()> { + if let Some(sys) = request + .messages + .iter() + .find(|m| matches!(m, TaMessage::System(_))) + { + crate::openhuman::agent::harness::compaction::cache_align::warn_if_volatile( + &sys.text(), + ); + } + Ok(()) + } +} + +/// `before_model`: clear the bodies of older tool-result messages, keeping the +/// `keep_recent` most recent verbatim. The graph analogue of +/// `context::microcompact` — bounds a tool-heavy thread's cost without dropping +/// any chat turns. Idempotent: an already-cleared body is left as the +/// placeholder. +struct MicrocompactMiddleware { + keep_recent: usize, +} + +#[async_trait] +impl Middleware<()> for MicrocompactMiddleware { + fn name(&self) -> &str { + "microcompact" + } + + async fn before_model( + &self, + _ctx: &mut RunContext<()>, + _state: &(), + request: &mut ModelRequest, + ) -> TaResult<()> { + let tool_idxs: Vec = request + .messages + .iter() + .enumerate() + .filter(|(_, m)| matches!(m, TaMessage::Tool(_))) + .map(|(i, _)| i) + .collect(); + if tool_idxs.len() <= self.keep_recent { + return Ok(()); + } + let cut = tool_idxs.len() - self.keep_recent; + for &i in &tool_idxs[..cut] { + // Skip messages already reduced to the placeholder; otherwise swap the + // body for it (idempotent, preserves the tool_call_id). + if request.messages[i].text() == CLEARED_PLACEHOLDER { + continue; + } + if let TaMessage::Tool(t) = &request.messages[i] { + let id = t.tool_call_id.clone(); + request.messages[i] = TaMessage::tool(id, CLEARED_PLACEHOLDER); + } + } + Ok(()) + } +} + +/// `after_tool`: apply the semantic payload summarizer (when configured) and +/// then the hard per-tool-result byte cap to each tool result's model-facing +/// content, before it enters the transcript. The graph analogue of the byte cap +/// + `payload_summarizer` interception the in-house `agent_tool_exec` ran. +struct ToolOutputMiddleware { + /// Fallback per-tool-result byte cap for tools that don't declare their own. + budget_bytes: usize, + payload_summarizer: Option>, + /// Shared tool sets, used to honor a tool's own `max_result_size_chars()` + /// cap (issue #4249, Phase 1 Task C) instead of the flat `budget_bytes`. + tool_sets: Vec>>>, +} + +impl ToolOutputMiddleware { + /// Effective byte cap for `name`: the tool's own `max_result_size_chars` + /// when it declares one (treated as bytes — a conservative approximation), + /// else the shared fallback `budget_bytes`. + fn effective_budget(&self, name: &str) -> usize { + self.tool_sets + .iter() + .flat_map(|set| set.iter()) + .find(|t| t.name() == name) + .and_then(|t| t.max_result_size_chars()) + .unwrap_or(self.budget_bytes) + } +} + +#[async_trait] +impl Middleware<()> for ToolOutputMiddleware { + fn name(&self) -> &str { + "tool_output_budget" + } + + async fn after_tool( + &self, + _ctx: &mut RunContext<()>, + _state: &(), + result: &mut TaToolResult, + ) -> TaResult<()> { + // 1. Semantic summarization (progressive disclosure) — swap the raw + // payload for a compressed summary when the summarizer opts in. + // Failures never break the tool call (the trait swallows them). + if let Some(ps) = &self.payload_summarizer { + if let Ok(Some(payload)) = ps + .maybe_summarize(&result.name, None, &result.content) + .await + { + tracing::info!( + tool = %result.name, + from_bytes = payload.original_bytes, + to_bytes = payload.summary_bytes, + "[tinyagents::mw] payload_summarizer compressed tool output" + ); + result.content = payload.summary; + } + } + + // 2. Hard byte cap backstop — truncate at a UTF-8 boundary with a marker. + // Honor the tool's own declared cap first, else the shared fallback. + let budget = self.effective_budget(&result.name); + if budget > 0 { + let (capped, outcome) = + apply_tool_result_budget(std::mem::take(&mut result.content), budget); + if outcome.truncated { + tracing::debug!( + tool = %result.name, + from_bytes = outcome.original_bytes, + to_bytes = outcome.final_bytes, + "[tinyagents::mw] tool_result_budget truncated tool output" + ); + } + result.content = capped; + } + Ok(()) + } +} + +/// `wrap_tool`: route OpenHuman's human-in-the-loop **approval gate** through a +/// named tinyagents tool middleware (issue #4249, Phase 1). A tool with an +/// external effect intercepts through the global [`ApprovalGate`]; a denial +/// short-circuits with the reason as a model-consumable [`TaToolResult`] +/// (`next` is never called), and an allowed call records a terminal audit row +/// once the tool resolves. +/// +/// This replaces the inline approval block that used to live in +/// `execute_openhuman_tool`, giving approval a stable middleware name and +/// letting it short-circuit cleanly. Tool-*internal* security (path/command +/// policy via `live_policy`) stays inside each tool — it needs tool-specific +/// operation semantics the harness boundary can't reconstruct generically. +pub struct ApprovalSecurityMiddleware { + /// The same `Arc`-shared tool sets the runner registers, used to resolve a + /// call's OpenHuman `Tool` by name so `external_effect_with_args` can gate. + tool_sets: Vec>>>, +} + +impl ApprovalSecurityMiddleware { + /// Build the middleware over the runner's shared tool sets. + pub fn new(tool_sets: Vec>>>) -> Self { + Self { tool_sets } + } + + /// Whether the named tool declares an external effect for these args. + fn has_external_effect(&self, name: &str, args: &serde_json::Value) -> bool { + self.tool_sets + .iter() + .flat_map(|set| set.iter()) + .find(|t| t.name() == name) + .map(|t| t.external_effect_with_args(args)) + .unwrap_or(false) + } +} + +#[async_trait] +impl ToolMiddleware<()> for ApprovalSecurityMiddleware { + fn name(&self) -> &str { + "approval_security" + } + + async fn wrap_tool( + &self, + ctx: &mut RunContext<()>, + state: &(), + call: TaToolCall, + next: ToolHandler<'_, (), ()>, + ) -> TaResult { + // Resolve external-effect up front so no tool borrow is held across the + // approval await. + let mut audit_id: Option = None; + if self.has_external_effect(&call.name, &call.arguments) { + if let Some(gate) = ApprovalGate::try_global() { + let summary = summarize_action(&call.name, &call.arguments); + let redacted = redact_args(&call.arguments); + let (outcome, request_id) = + gate.intercept_audited(&call.name, &summary, redacted).await; + match outcome { + GateOutcome::Deny { reason } => { + tracing::warn!( + tool = %call.name, + reason = %reason, + "[tinyagents::mw] approval gate denied tool call" + ); + return Ok(MiddlewareToolOutcome::Result(TaToolResult { + call_id: call.id, + name: call.name, + content: reason.clone(), + raw: None, + error: Some(reason), + elapsed_ms: 0, + })); + } + GateOutcome::Allow => audit_id = request_id, + } + } + } + + let outcome = next.run(ctx, state, call).await?; + + // Record the terminal audit row for an approved external-effect call + // (idempotent; a no-op when the id is unknown). + if let Some(id) = audit_id { + if let Some(gate) = ApprovalGate::try_global() { + if let MiddlewareToolOutcome::Result(res) = &outcome { + let exec = if res.error.is_some() { + ExecutionOutcome::Failure + } else { + ExecutionOutcome::Success + }; + gate.record_execution(&id, exec, res.error.as_deref()); + } + } + } + Ok(outcome) + } +} + +/// `before_tool`: rewrite a call to an **unadvertised** tool onto the recovery +/// sentinel (issue #4249, Phase 1 Task B) so a hallucinated tool name is a +/// recoverable [`UnknownToolAdapter`](super::tools::UnknownToolAdapter) result +/// rather than a fatal `ToolNotFound`. `before_tool` runs before the harness +/// resolves the tool, so the rewrite lands in time. +/// +/// This moves the decision out of `ProviderModel::response_to_model_response` +/// (which used to carry a `valid_tools` set) to the tool boundary, where it +/// applies uniformly to native and text-parsed tool calls. The sentinel handler +/// is still required (the crate has no "tool not found → repair" hook — SDK gap), +/// but it remains internal and is never advertised to the model. +pub struct UnknownToolRewriteMiddleware { + /// The set of callable tool names (plus the sentinel). A call outside it is + /// rewritten onto the sentinel. + valid: Arc>, +} + +impl UnknownToolRewriteMiddleware { + /// Build the middleware over the runner's valid-tool-name set. + pub fn new(valid: Arc>) -> Self { + Self { valid } + } +} + +#[async_trait] +impl Middleware<()> for UnknownToolRewriteMiddleware { + fn name(&self) -> &str { + "unknown_tool_rewrite" + } + + async fn before_tool( + &self, + _ctx: &mut RunContext<()>, + _state: &(), + call: &mut TaToolCall, + ) -> TaResult<()> { + if call.name != UNKNOWN_TOOL_SENTINEL && !self.valid.contains(&call.name) { + let requested = std::mem::take(&mut call.name); + tracing::debug!( + requested = %requested, + "[tinyagents::mw] rewriting unknown tool call onto recovery sentinel" + ); + call.arguments = serde_json::json!({ "requested_tool": requested }); + call.name = UNKNOWN_TOOL_SENTINEL.to_string(); + } + Ok(()) + } +} + +/// `before_model`: enforce OpenHuman's daily/monthly cost budgets **before** a +/// model call spends (issue #4249, Phase 5). Reads the global +/// [`CostTracker`](crate::openhuman::cost) and, when cost budgets are configured +/// and already exceeded, fails the run before the provider call; a warning +/// threshold logs but proceeds. +/// +/// Self-gating: a no-op unless a global tracker exists and `config.enabled` with +/// a limit is set (`check_budget` returns `Allowed` otherwise). Complements the +/// post-call `StopHookMiddleware` per-turn USD cap. Projecting the *next* call's +/// cost pre-spend (vs the already-exceeded check here) needs an input-token +/// estimate — a follow-up. +pub struct CostBudgetMiddleware; + +#[async_trait] +impl Middleware<()> for CostBudgetMiddleware { + fn name(&self) -> &str { + "cost_budget" + } + + async fn before_model( + &self, + _ctx: &mut RunContext<()>, + _state: &(), + _request: &mut ModelRequest, + ) -> TaResult<()> { + use crate::openhuman::cost::types::BudgetCheck; + let Some(tracker) = crate::openhuman::cost::try_global() else { + return Ok(()); + }; + // Pass 0.0 to test whether we are *already* over budget before spending + // more (rather than projecting this call's cost, which needs a token + // estimate). + match tracker.check_budget(0.0) { + Ok(BudgetCheck::Exceeded { + current_usd, + limit_usd, + period, + }) => { + tracing::warn!( + %current_usd, %limit_usd, ?period, + "[tinyagents::mw] cost budget exceeded — failing before model call" + ); + Err(tinyagents::TinyAgentsError::LimitExceeded(format!( + "cost budget exceeded: {period:?} spend ${current_usd:.4} \u{2265} limit ${limit_usd:.4}" + ))) + } + Ok(BudgetCheck::Warning { + current_usd, + limit_usd, + period, + }) => { + tracing::warn!( + %current_usd, %limit_usd, ?period, + "[tinyagents::mw] cost budget warning threshold reached" + ); + Ok(()) + } + _ => Ok(()), + } + } +} + +/// `after_tool`: stop the run when a tool returns the **same** error result +/// repeatedly (issue #4249). The legacy tool loop's progress guard halted on a +/// repeated deterministic failure — a security/approval denial, or a terminal +/// tool error the model keeps reissuing — so the run surfaced the root cause +/// instead of burning the whole iteration budget and then a generic cap failure. +/// The tinyagents path kept only the generic model/tool call caps, so this +/// reinstates the breaker as a graph middleware: after `threshold` consecutive +/// identical error signatures (`tool name` + error text), it pauses the run via +/// the shared steering handle (the same mechanism as the stop-hook / cap pausers). +/// Any successful tool result resets the counter — progress was made. +pub struct RepeatedToolFailureMiddleware { + handle: SteeringHandle, + threshold: usize, + last: std::sync::Mutex>, +} + +impl RepeatedToolFailureMiddleware { + /// Build the breaker. `threshold` is clamped to at least 2 (a single failure + /// is never a loop). + pub fn new(handle: SteeringHandle, threshold: usize) -> Self { + Self { + handle, + threshold: threshold.max(2), + last: std::sync::Mutex::new(None), + } + } +} + +#[async_trait] +impl Middleware<()> for RepeatedToolFailureMiddleware { + fn name(&self) -> &str { + "repeated_tool_failure" + } + + async fn after_tool( + &self, + _ctx: &mut RunContext<()>, + _state: &(), + result: &mut TaToolResult, + ) -> TaResult<()> { + let mut guard = self.last.lock().unwrap(); + let Some(err) = result.error.as_deref() else { + // Success → progress was made; reset the breaker. + *guard = None; + return Ok(()); + }; + // Signature: tool name + error text. Truncate the error so a huge payload + // doesn't dominate the comparison (the first line is the deterministic part). + let sig = format!("{}\u{1f}{}", result.name, err.lines().next().unwrap_or(err)); + let count = match guard.as_mut() { + Some((prev, c)) if *prev == sig => { + *c += 1; + *c + } + _ => { + *guard = Some((sig, 1)); + 1 + } + }; + if count >= self.threshold { + tracing::warn!( + tool = %result.name, + count, + threshold = self.threshold, + "[tinyagents::mw] repeated identical tool failure — pausing run so the root cause surfaces" + ); + // Pause at the top of the next iteration (before the next model call), + // matching the stop-hook / cap pause path. Reset so a resumed run does + // not immediately re-pause on the same latched signature. + self.handle.send(SteeringCommand::Pause); + *guard = None; + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use tinyagents::harness::context::{RunConfig, RunContext}; + use tinyagents::harness::model::ModelRequest; + + fn ctx() -> RunContext<()> { + RunContext::new(RunConfig::new("mw-test"), ()) + } + + /// 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. + struct FakeTool { + name: &'static str, + cap: Option, + external: bool, + } + + #[async_trait] + impl Tool for FakeTool { + fn name(&self) -> &str { + self.name + } + fn description(&self) -> &str { + "fake" + } + fn parameters_schema(&self) -> serde_json::Value { + json!({ "type": "object" }) + } + async fn execute( + &self, + _args: serde_json::Value, + ) -> anyhow::Result { + Ok(crate::openhuman::tools::ToolResult::success("ok")) + } + fn max_result_size_chars(&self) -> Option { + self.cap + } + fn external_effect_with_args(&self, _args: &serde_json::Value) -> bool { + self.external + } + } + + fn tool_result(name: &str, content: &str) -> TaToolResult { + TaToolResult { + call_id: "c1".into(), + name: name.into(), + content: content.into(), + raw: None, + error: None, + elapsed_ms: 0, + } + } + + // ── TurnContextMiddleware config ──────────────────────────────────────── + + #[test] + fn defaults_enable_cache_align_and_the_byte_cap_only() { + let mw = TurnContextMiddleware::defaults(); + assert!(mw.cache_align); + assert_eq!( + mw.tool_result_budget_bytes, + DEFAULT_TOOL_RESULT_BUDGET_BYTES + ); + assert!(mw.payload_summarizer.is_none()); + assert_eq!(mw.microcompact_keep_recent, 0); + // Autocompaction defaults on (channel/sub-agent); the chat path overrides + // it from config. + assert!(mw.autocompact_enabled); + assert!(!mw.is_empty()); + } + + #[test] + fn an_all_default_bundle_installs_nothing() { + assert!(TurnContextMiddleware::default().is_empty()); + } + + // ── SuperContextMiddleware helpers ────────────────────────────────────── + + #[test] + fn super_context_is_off_by_default() { + assert!(TurnContextMiddleware::defaults().super_context.is_none()); + assert!(TurnContextMiddleware::default().super_context.is_none()); + } + + #[test] + fn parse_bundle_sufficiency_reads_the_marker_case_insensitively() { + assert_eq!( + parse_context_bundle_has_enough_context( + "[context_bundle]\nhas_enough_context: true\n[/context_bundle]" + ), + Some(true) + ); + assert_eq!( + parse_context_bundle_has_enough_context("HAS_ENOUGH_CONTEXT: false"), + Some(false) + ); + assert_eq!( + parse_context_bundle_has_enough_context("[context_bundle]\nsummary: ok"), + None + ); + } + + #[test] + fn prepend_folds_bundle_ahead_of_the_last_user_message_keeping_images() { + use tinyagents::harness::message::ImageRef; + let mut msgs = vec![TaMessage::system("sys"), { + // A multimodal user turn: text + an image block. + let mut u = TaMessage::user("original ask"); + if let TaMessage::User(m) = &mut u { + m.content.push(ContentBlock::Image(ImageRef { + url: "data:image/png;base64,AAAA".into(), + mime_type: None, + })); + } + u + }]; + + prepend_text_to_last_user(&mut msgs, "BUNDLE_BLOCK\n\n".to_string()); + + let TaMessage::User(m) = &msgs[1] else { + panic!("expected a user message"); + }; + // Bundle rides in front as a new leading text block. + assert!( + matches!(&m.content[0], ContentBlock::Text(t) if t.starts_with("BUNDLE_BLOCK")), + "bundle should be the leading text block" + ); + // Original text and the image both survive. + assert!(m + .content + .iter() + .any(|b| matches!(b, ContentBlock::Text(t) if t.contains("original ask")))); + assert!( + m.content + .iter() + .any(|b| matches!(b, ContentBlock::Image(_))), + "the image block must survive the fold" + ); + // System message untouched. + assert_eq!(msgs[0].text(), "sys"); + } + + #[test] + fn prepend_is_a_noop_without_a_user_message() { + let mut msgs = vec![TaMessage::system("only system")]; + prepend_text_to_last_user(&mut msgs, "IGNORED".to_string()); + assert_eq!(msgs.len(), 1); + assert_eq!(msgs[0].text(), "only system"); + } + + // ── MicrocompactMiddleware ────────────────────────────────────────────── + + #[tokio::test] + async fn microcompact_clears_older_tool_bodies_and_keeps_recent() { + let mw = MicrocompactMiddleware { keep_recent: 1 }; + let mut req = ModelRequest::new(vec![ + TaMessage::system("sys"), + TaMessage::user("hello"), + TaMessage::tool("t1", "FIRST_BODY"), + TaMessage::assistant("thinking"), + TaMessage::tool("t2", "SECOND_BODY"), + TaMessage::tool("t3", "THIRD_BODY"), + ]); + + mw.before_model(&mut ctx(), &(), &mut req).await.unwrap(); + + // 3 tool messages, keep_recent=1 → the two oldest cleared, newest kept. + assert_eq!(req.messages[2].text(), CLEARED_PLACEHOLDER); + assert_eq!(req.messages[4].text(), CLEARED_PLACEHOLDER); + assert_eq!(req.messages[5].text(), "THIRD_BODY"); + // Non-tool messages are never touched. + assert_eq!(req.messages[0].text(), "sys"); + assert_eq!(req.messages[1].text(), "hello"); + assert_eq!(req.messages[3].text(), "thinking"); + } + + #[tokio::test] + async fn microcompact_is_a_noop_when_within_keep_recent() { + let mw = MicrocompactMiddleware { keep_recent: 5 }; + let mut req = + ModelRequest::new(vec![TaMessage::tool("t1", "A"), TaMessage::tool("t2", "B")]); + mw.before_model(&mut ctx(), &(), &mut req).await.unwrap(); + assert_eq!(req.messages[0].text(), "A"); + assert_eq!(req.messages[1].text(), "B"); + } + + #[tokio::test] + async fn microcompact_is_idempotent() { + let mw = MicrocompactMiddleware { keep_recent: 1 }; + let mut req = ModelRequest::new(vec![ + TaMessage::tool("t1", "FIRST"), + TaMessage::tool("t2", "SECOND"), + ]); + mw.before_model(&mut ctx(), &(), &mut req).await.unwrap(); + let after_first = req.messages[0].text(); + assert_eq!(after_first, CLEARED_PLACEHOLDER); + // Second pass leaves the already-cleared body as the placeholder. + mw.before_model(&mut ctx(), &(), &mut req).await.unwrap(); + assert_eq!(req.messages[0].text(), CLEARED_PLACEHOLDER); + assert_eq!(req.messages[1].text(), "SECOND"); + } + + // ── ToolOutputMiddleware ──────────────────────────────────────────────── + + #[tokio::test] + async fn tool_output_truncates_over_the_flat_budget() { + let mw = ToolOutputMiddleware { + budget_bytes: 100, + payload_summarizer: None, + tool_sets: vec![], + }; + let mut result = tool_result("echo", &"x".repeat(5_000)); + mw.after_tool(&mut ctx(), &(), &mut result).await.unwrap(); + assert!(result.content.len() < 5_000, "content should be capped"); + assert!( + result.content.contains("truncated by tool_result_budget"), + "a truncation marker should be appended: {}", + result.content + ); + } + + #[tokio::test] + async fn tool_output_leaves_small_results_untouched() { + let mw = ToolOutputMiddleware { + budget_bytes: 1_000, + payload_summarizer: None, + tool_sets: vec![], + }; + let mut result = tool_result("echo", "tiny"); + mw.after_tool(&mut ctx(), &(), &mut result).await.unwrap(); + assert_eq!(result.content, "tiny"); + } + + #[test] + fn effective_budget_prefers_the_tools_own_cap() { + let tools: Arc>> = Arc::new(vec![Box::new(FakeTool { + name: "big", + cap: Some(10), + external: false, + })]); + let mw = ToolOutputMiddleware { + budget_bytes: 1_000, + payload_summarizer: None, + tool_sets: vec![tools], + }; + // Tool declares its own cap → used instead of the flat fallback. + assert_eq!(mw.effective_budget("big"), 10); + // Unknown tool → the flat fallback. + assert_eq!(mw.effective_budget("other"), 1_000); + } + + #[tokio::test] + async fn tool_output_honors_a_tools_own_cap() { + let tools: Arc>> = Arc::new(vec![Box::new(FakeTool { + name: "capped", + cap: Some(20), + external: false, + })]); + let mw = ToolOutputMiddleware { + budget_bytes: 100_000, + payload_summarizer: None, + tool_sets: vec![tools], + }; + let mut result = tool_result("capped", &"y".repeat(500)); + mw.after_tool(&mut ctx(), &(), &mut result).await.unwrap(); + assert!( + result.content.contains("truncated by tool_result_budget"), + "the tool's own 20-byte cap should truncate: {}", + result.content + ); + } + + // ── UnknownToolRewriteMiddleware ──────────────────────────────────────── + + #[tokio::test] + async fn unknown_tool_is_rewritten_onto_the_recovery_sentinel() { + let valid: Arc> = Arc::new(["echo".to_string()].into_iter().collect()); + let mw = UnknownToolRewriteMiddleware::new(valid); + let mut call = TaToolCall { + id: "1".into(), + name: "frobnicate".into(), + arguments: json!({ "x": 1 }), + }; + mw.before_tool(&mut ctx(), &(), &mut call).await.unwrap(); + assert_eq!(call.name, UNKNOWN_TOOL_SENTINEL); + assert_eq!(call.arguments["requested_tool"], json!("frobnicate")); + } + + #[tokio::test] + async fn advertised_tool_is_left_untouched() { + let valid: Arc> = Arc::new(["echo".to_string()].into_iter().collect()); + let mw = UnknownToolRewriteMiddleware::new(valid); + let mut call = TaToolCall { + id: "1".into(), + name: "echo".into(), + arguments: json!({ "msg": "hi" }), + }; + mw.before_tool(&mut ctx(), &(), &mut call).await.unwrap(); + assert_eq!(call.name, "echo"); + assert_eq!(call.arguments, json!({ "msg": "hi" })); + } + + #[tokio::test] + async fn the_sentinel_itself_is_never_rewritten() { + let valid: Arc> = Arc::new(HashSet::new()); + let mw = UnknownToolRewriteMiddleware::new(valid); + let mut call = TaToolCall { + id: "1".into(), + name: UNKNOWN_TOOL_SENTINEL.to_string(), + arguments: json!({ "requested_tool": "x" }), + }; + mw.before_tool(&mut ctx(), &(), &mut call).await.unwrap(); + assert_eq!(call.name, UNKNOWN_TOOL_SENTINEL); + } + + // ── CostBudgetMiddleware ──────────────────────────────────────────────── + + #[tokio::test] + async fn cost_budget_is_a_noop_without_a_global_tracker() { + // No global CostTracker is installed in the unit-test process, so the + // gate self-disables and the model call proceeds. + let mw = CostBudgetMiddleware; + let mut req = ModelRequest::new(vec![TaMessage::user("hi")]); + assert!(mw.before_model(&mut ctx(), &(), &mut req).await.is_ok()); + } + + // ── RepeatedToolFailureMiddleware ─────────────────────────────────────── + + fn failing_result(name: &str, err: &str) -> TaToolResult { + let mut r = tool_result(name, err); + r.error = Some(err.to_string()); + r + } + + #[tokio::test] + async fn repeated_tool_failure_pauses_only_after_the_threshold() { + let handle = SteeringHandle::allow_all(); + let mw = RepeatedToolFailureMiddleware::new(handle.clone(), 3); + // Two identical failures: below the threshold, no pause. + for _ in 0..2 { + let mut r = failing_result("flaky", "boom"); + mw.after_tool(&mut ctx(), &(), &mut r).await.unwrap(); + } + assert_eq!(handle.pending(), 0, "no pause before the threshold"); + // Third identical failure trips the breaker. + let mut r = failing_result("flaky", "boom"); + mw.after_tool(&mut ctx(), &(), &mut r).await.unwrap(); + assert!( + handle.pending() >= 1, + "the third identical failure should pause the run" + ); + } + + #[tokio::test] + async fn repeated_tool_failure_resets_on_a_success() { + let handle = SteeringHandle::allow_all(); + let mw = RepeatedToolFailureMiddleware::new(handle.clone(), 3); + // Two failures, then a success clears the counter. + for _ in 0..2 { + let mut r = failing_result("t", "boom"); + mw.after_tool(&mut ctx(), &(), &mut r).await.unwrap(); + } + let mut ok = tool_result("t", "fine"); // error = None + mw.after_tool(&mut ctx(), &(), &mut ok).await.unwrap(); + // Two more failures — still below the threshold because the counter reset. + for _ in 0..2 { + let mut r = failing_result("t", "boom"); + mw.after_tool(&mut ctx(), &(), &mut r).await.unwrap(); + } + assert_eq!(handle.pending(), 0, "a success should reset the breaker"); + } + + #[tokio::test] + async fn repeated_tool_failure_ignores_distinct_errors() { + let handle = SteeringHandle::allow_all(); + let mw = RepeatedToolFailureMiddleware::new(handle.clone(), 3); + // Three *different* errors never trip the breaker — only an identical, + // deterministic failure loop does. + for err in ["e1", "e2", "e3"] { + let mut r = failing_result("t", err); + mw.after_tool(&mut ctx(), &(), &mut r).await.unwrap(); + } + assert_eq!( + handle.pending(), + 0, + "distinct errors must not trip the breaker" + ); + } + + // ── ApprovalSecurityMiddleware ────────────────────────────────────────── + + #[test] + fn approval_external_effect_resolution_walks_the_tool_sets() { + let tools: Arc>> = Arc::new(vec![ + Box::new(FakeTool { + name: "send_email", + cap: None, + external: true, + }), + Box::new(FakeTool { + name: "read_file", + cap: None, + external: false, + }), + ]); + let mw = ApprovalSecurityMiddleware::new(vec![tools]); + assert!(mw.has_external_effect("send_email", &json!({}))); + assert!(!mw.has_external_effect("read_file", &json!({}))); + // Unknown tool defaults to no external effect (nothing to gate). + assert!(!mw.has_external_effect("missing", &json!({}))); + } +} diff --git a/src/openhuman/tinyagents/mod.rs b/src/openhuman/tinyagents/mod.rs new file mode 100644 index 000000000..188870f53 --- /dev/null +++ b/src/openhuman/tinyagents/mod.rs @@ -0,0 +1,632 @@ +//! `tinyagents` integration — drive an openhuman agent turn on the published +//! [`tinyagents`](https://crates.io/crates/tinyagents) orchestration framework +//! (issue #4249). +//! +//! openhuman's agent execution runs on the `tinyagents` crate +//! (LangGraph/LangChain-style durable graphs + an agent-loop harness with model/ +//! tool registries, middleware, retry/fallback, and limits). This module is the +//! **adapter seam**: it bridges openhuman's `Provider`, `Tool`, and `ChatMessage` +//! types onto the crate's `ChatModel`, `Tool`, and `Message` traits, then drives +//! a turn through [`AgentHarness::invoke`]. The chat / channel / sub-agent +//! routes call [`run_turn_via_tinyagents_shared`] (default ON in production). +//! +//! The chat route is at functional parity with the legacy `run_turn_engine`: +//! the [`OpenhumanEventBridge`] mirrors the 0.2.0 harness event stream onto +//! `AgentProgress` (live tool timeline, incremental text deltas, cost footer), +//! [`ProviderModel::stream`] forwards true token streaming, multimodal markers +//! are expanded, and history is trimmed to the context window. Mid-flight +//! steering, sub-agent child-progress deltas (incl. thinking), and the +//! `ask_user_clarification` early-exit pause are all re-wired onto 0.2.0. + +pub mod checkpoint; +mod convert; +pub mod delegation; +pub mod middleware; +mod model; +pub mod observability; +pub mod orchestration; +pub mod stop_hooks; +pub mod summarize; +mod tools; +pub mod topology; + +use std::sync::Arc; + +use anyhow::Result; +use tinyagents::harness::context::{RunConfig, RunContext}; +use tinyagents::harness::events::EventSink; +use tinyagents::harness::message::Message as TaMessage; +use tinyagents::harness::middleware::{ContextCompressionMiddleware, MessageTrimMiddleware}; +use tinyagents::harness::runtime::{AgentHarness, RunPolicy}; +use tinyagents::harness::steering::{SteeringCommand, SteeringHandle}; +use tinyagents::harness::summarization::TrimStrategy; + +use crate::openhuman::agent::harness::run_queue::RunQueue; +use crate::openhuman::agent::progress::AgentProgress; +use crate::openhuman::inference::provider::{ChatMessage, ConversationMessage, Provider}; + +pub use checkpoint::SqlRunLedgerCheckpointer; +pub use middleware::{SuperContextConfig, TurnContextMiddleware}; +pub use model::{ProviderModel, ThinkingForwarder}; +pub use observability::{CapPauser, IterationCursor, OpenhumanEventBridge, SubagentScope}; +pub use tools::{ + EarlyExit, EarlyExitHook, SharedToolAdapter, ToolAdapter, UnknownToolAdapter, + UNKNOWN_TOOL_SENTINEL, +}; + +use std::collections::HashSet; +use tokio::sync::mpsc::Sender; + +/// Drain the run queue's pending steer messages and forward them to the +/// tinyagents [`SteeringHandle`] as injected user turns (the harness applies +/// them to the working transcript at the next iteration checkpoint). This is the +/// bridge behind the `steer_subagent` / mid-flight-steering feature. +async fn forward_steers(queue: &RunQueue, handle: &SteeringHandle) { + for msg in queue.drain_steers().await { + handle.send(SteeringCommand::InjectMessage(TaMessage::user(format!( + "[User steering message]: {}", + msg.text + )))); + } +} + +/// Build the harness [`RunPolicy`] for an openhuman turn. +/// +/// The loop enforces limits from `self.policy.limits` (not the per-run +/// `RunConfig`), so the model-call cap **must** be set here or it falls back to +/// the tinyagents default of 25 — far more than openhuman's `max_iterations`. +/// Retry is set to a single attempt: the openhuman [`Provider`] already does its +/// own internal retry/backoff, so a second harness-level retry layer would +/// double-retry transient errors and, worse, swallow a deterministic provider +/// error when a mock/test provider yields a different result on the retry. +fn run_policy_for(max_iterations: usize) -> RunPolicy { + let mut policy = RunPolicy::default(); + policy.limits.max_model_calls = max_iterations; + policy.limits.max_tool_calls = max_iterations.saturating_mul(8).max(8); + policy.retry.max_attempts = 1; + policy +} + +/// Consecutive identical tool failures that trip the repeated-failure circuit +/// breaker (see `middleware::RepeatedToolFailureMiddleware`). Three matches the +/// legacy progress-guard's tolerance before it halted a stuck loop. +const REPEATED_TOOL_FAILURE_THRESHOLD: usize = 3; + +/// Legacy default model-call cap used when a caller passes `max_iterations == 0` +/// to request "unset" (native-bus / test callers relied on the old loop treating +/// `max_tool_iterations == 0` as the default of 10). Passing `0` straight through +/// would set the harness `max_model_calls` to zero and abort before the first +/// provider call, so the runners normalize `0` to this value. +const DEFAULT_MAX_ITERATIONS: usize = 10; + +/// Normalize a caller-supplied iteration cap: `0` means "unset" → the default. +fn effective_max_iterations(max_iterations: usize) -> usize { + if max_iterations == 0 { + DEFAULT_MAX_ITERATIONS + } else { + max_iterations + } +} + +/// The outcome of a turn driven on the `tinyagents` harness. +#[derive(Debug, Clone)] +pub struct TinyagentsTurnOutcome { + /// Final assistant text. + pub text: String, + /// The full transcript, converted back to openhuman messages (flat — tool + /// calls rendered as text). + pub history: Vec, + /// The **typed** messages this turn appended (after the user turn): + /// `AssistantToolCalls` / `ToolResults` / final assistant `Chat`. The chat + /// session persists these to keep structured tool-call history fidelity. + pub conversation: Vec, + /// Number of model calls the loop made. + pub model_calls: usize, + /// Number of tool calls the loop made. + pub tool_calls: usize, + /// Accumulated input tokens. + pub input_tokens: u64, + /// Accumulated output tokens. + pub output_tokens: u64, + /// Accumulated cached (cache-read) input tokens. Carried so the turn persists + /// real cached usage instead of zero (issue #4249, Phase 5). + pub cached_input_tokens: u64, + /// Estimated charged USD for the turn (from `cost::catalog::estimate_cost_usd` + /// over the observed usage). Carried so the transcript / session meters record + /// a real cost instead of `$0` on every non-cap turn. + pub charged_amount_usd: f64, + /// Set when an early-exit tool (e.g. `ask_user_clarification`) fired: the + /// loop paused so the caller can checkpoint and surface the question. When + /// present, `text` holds the question. Mirrors the legacy `early_exit_tool`. + pub early_exit_tool: Option, + /// `true` when the run stopped because it reached the model-call cap with + /// work still pending (the last response requested more tools). The caller + /// should summarize a resumable checkpoint rather than treat `text` as a + /// final answer — the tinyagents analogue of `CheckpointStrategy::on_max_iter`. + pub hit_cap: bool, +} + +/// Drive an agent turn through the `tinyagents` agent-loop harness. +/// +/// Registers `provider` as the default model and every entry in `resolved_tools` +/// as a harness tool, seeds the loop with `history`, and runs the loop bounded +/// by `max_iterations` model calls. Returns the final text plus the resulting +/// transcript translated back to openhuman [`ChatMessage`]s. +pub async fn run_turn_via_tinyagents( + provider: Arc, + model: &str, + temperature: f64, + history: Vec, + resolved_tools: Vec>, + max_iterations: usize, +) -> Result { + // `0` means "unset" → the legacy default; otherwise the harness cap would be + // zero and the run would abort before the first model call. + let max_iterations = effective_max_iterations(max_iterations); + let mut harness: AgentHarness<()> = AgentHarness::new(); + harness.with_policy(run_policy_for(max_iterations)); + let provider_model = ProviderModel::new(provider, model, temperature); + let error_slot = provider_model.error_slot(); + harness + .register_model(model, Arc::new(provider_model)) + .set_default_model(model); + let tool_count = resolved_tools.len(); + for tool in resolved_tools { + harness.register_tool(Arc::new(ToolAdapter::new(tool))); + } + + // Bound the run: one model call per legacy "iteration", and allow generous + // tool calls (the loop also stops when the model stops requesting tools). + let config = RunConfig::new("agent_turn") + .with_max_model_calls(max_iterations) + .with_max_tool_calls(max_iterations.saturating_mul(8).max(8)); + + tracing::info!( + model, + max_iterations, + tools = tool_count, + "[tinyagents] routing agent turn through tinyagents harness" + ); + + let input = convert::history_to_messages(&history); + // Box the (large) harness drive future — see `run_turn_via_tinyagents_shared`. + let run = match Box::pin(harness.invoke(&(), (), config, input)).await { + Ok(run) => run, + Err(e) => { + if let Some(original) = error_slot.lock().unwrap().take() { + return Err(original); + } + return Err(anyhow::anyhow!("tinyagents harness run failed: {e}")); + } + }; + + let text = run.text().unwrap_or_default(); + let out_history = convert::messages_to_history(&run.messages); + let conversation = + convert::messages_to_conversation(convert::messages_since_last_user(&run.messages)); + + Ok(TinyagentsTurnOutcome { + text, + history: out_history, + conversation, + model_calls: run.model_calls, + tool_calls: run.tool_calls, + input_tokens: run.usage.usage.input_tokens, + output_tokens: run.usage.usage.output_tokens, + cached_input_tokens: run.usage.usage.cache_read_tokens, + charged_amount_usd: crate::openhuman::cost::catalog::estimate_cost_usd( + model, + run.usage.usage.input_tokens, + run.usage.usage.output_tokens, + run.usage.usage.cache_read_tokens, + ), + early_exit_tool: None, + hit_cap: false, + }) +} + +/// Drive a turn through the tinyagents harness over the routes' **shared**, +/// `Arc`-owned tool registry sets (`Arc>>`), advertising +/// exactly `specs` (already filtered/deduped by the caller's visibility rules). +/// +/// This is the entry point the channel/sub-agent routes use to retire the +/// in-house `live` turn machine: it registers a [`SharedToolAdapter`] per +/// advertised spec so the same `Arc`-shared tools the legacy loop runs are +/// reused without cloning. +/// +/// `allowed` is the callable tool-name whitelist (empty = every tool visible in +/// `tool_sets`); each callable tool is advertised via its own `spec()`. +/// +/// When `on_progress` is `Some`, the run streams (`invoke_streaming_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` +/// produced. Pass `None` for fire-and-forget turns (channel/sub-agent) that +/// only need the final text. +/// +/// When `context_window` is known, a [`MessageTrimMiddleware`] keeps history +/// under budget (autocompaction parity). +/// +/// `run_queue` forwards mid-flight steer messages into the run; `subagent_scope` +/// re-scopes progress to the `Subagent*` variants (child runs); `early_exit_tools` +/// name the tools that pause the loop (e.g. `ask_user_clarification`) and surface +/// the question via [`TinyagentsTurnOutcome::early_exit_tool`]. +#[allow(clippy::too_many_arguments)] +pub async fn run_turn_via_tinyagents_shared( + provider: Arc, + model: &str, + temperature: f64, + history: Vec, + tool_sets: Vec>>>, + allowed: HashSet, + max_iterations: usize, + on_progress: Option>, + subagent_scope: Option, + context_window: Option, + run_queue: Option>, + early_exit_tools: &[&str], + pause_at_cap: bool, + max_output_tokens: Option, + context_mw: TurnContextMiddleware, +) -> Result { + // `0` means "unset" → the legacy default (a native-bus / test convention); + // otherwise the harness model-call cap would be zero and abort the run before + // the first provider call. + let max_iterations = effective_max_iterations(max_iterations); + let mut harness: AgentHarness<()> = AgentHarness::new(); + harness.with_policy(run_policy_for(max_iterations)); + + // Shared 1-based model-call cursor: the event bridge advances it on each + // model start; the model adapter reads it to attribute out-of-band thinking + // deltas (tinyagents has no reasoning channel on its stream). + // The set of tool names the model may call: every advertised tool plus the + // unknown-tool sentinel. A call outside it is rewritten onto the sentinel so + // a hallucinated tool recovers instead of aborting the run — enforced by the + // `UnknownToolRewriteMiddleware` (`before_tool`) installed below. + let valid_tools: Arc> = { + let mut names: HashSet = tool_sets + .iter() + .flat_map(|set| set.iter()) + .map(|t| t.name().to_string()) + .filter(|name| allowed.is_empty() || allowed.contains(name)) + .collect(); + names.insert(UNKNOWN_TOOL_SENTINEL.to_string()); + Arc::new(names) + }; + + let cursor: IterationCursor = Arc::default(); + // Keep a provider handle for the context-window summarizer (the run consumes + // the other clone into the `ProviderModel`). + let summary_provider = provider.clone(); + let mut provider_model = ProviderModel::new(provider, model, temperature); + // Cap the model's per-call output budget (parity with the legacy engine, + // which bounded the main agent at `AGENT_TURN_MAX_OUTPUT_TOKENS` and each + // sub-agent at its `max_turn_output_tokens`). Without this the tinyagents + // path ran the provider uncapped. + if let Some(cap) = max_output_tokens { + provider_model = provider_model.with_max_tokens(cap); + } + if let Some(tx) = &on_progress { + provider_model = provider_model.with_thinking(ThinkingForwarder::new( + tx.clone(), + subagent_scope.clone(), + cursor.clone(), + )); + } + // Recover the original (downcastable) provider error if the run fails — the + // harness only carries a stringified copy. + let error_slot = provider_model.error_slot(); + harness + .register_model(model, Arc::new(provider_model)) + .set_default_model(model); + + // openhuman context concerns as graph middlewares (issue #4249): cache-align + // warnings, microcompact tool-body clearing, and the after-tool byte cap / + // payload summarizer. Installed before the summarization/trim block below so + // `before_model` hooks run cache-align → microcompact → compress → trim. + // Capture the autocompaction opt-out before `install` consumes `context_mw`. + let autocompact_enabled = context_mw.autocompact_enabled; + context_mw.install(&mut harness, &tool_sets); + + // Pre-call cost budget gate (issue #4249, Phase 5): fail before a model call + // when OpenHuman's daily/monthly cost budget is already exceeded. Self-gating + // — a no-op unless cost budgets are configured. + harness.push_middleware(Arc::new(middleware::CostBudgetMiddleware)); + + // Autocompaction parity: when the provider's context window is known, install + // the two-stage context-management step (issue #4249). + // + // 1. `ContextCompressionMiddleware` — the **summarization** step. Once the + // running token estimate crosses `window * SUMMARIZE_THRESHOLD_FRACTION` + // (90% of *this model's* context window), it folds the older slice of the + // transcript into a single LLM-generated system summary (keeping system + // messages + the recent window verbatim). This is keyed to whatever model + // the turn is running on, mirroring the legacy `ContextGuard` threshold. + // 2. `MessageTrimMiddleware` — a deterministic, no-extra-LLM-call hard cap. + // Pushed **after** compression (so `before_model` runs compression first), + // it front-trims to budget only as a last resort when even the summary + + // recent window still overflow. + // + // The LLM summarization step honors the `[context].enabled` / + // `autocompact_enabled` opt-outs (a disabled config must not spend summarizer + // tokens or rewrite history); the deterministic trim backstop always installs + // when a window is known, matching the legacy always-on `trim_history` cap. + if let Some(window) = context_window.filter(|w| *w > 0) { + if autocompact_enabled { + harness.push_middleware(Arc::new(ContextCompressionMiddleware::with_summarizer( + summarize::summarization_policy(window), + Box::new(summarize::ProviderModelSummarizer::new( + summary_provider, + model, + temperature, + )), + ))); + } + + let budget = window.saturating_sub( + crate::openhuman::inference::provider::AGENT_TURN_MAX_OUTPUT_TOKENS as u64, + ); + harness.push_middleware(Arc::new(MessageTrimMiddleware::new( + TrimStrategy::MaxTokens(budget.max(1024)), + ))); + } + + // Snapshot the installed stop hooks while the `CURRENT_STOP_HOOKS` + // task-local is in scope (the harness drive future runs inline on this + // task, but capturing here keeps the wiring robust). When present they fire + // via `StopHookMiddleware` and pause through the shared steering handle. + let stop_hooks = crate::openhuman::agent::stop_hooks::current_stop_hooks(); + + // A single steering handle drives mid-flight steering (run queue), the + // early-exit pause, the model-call-cap pause, and stop-hook pauses, so they + // all reach the same loop. Created when any of them is active. + // A steering handle is always created now: besides run-queue steering, the + // early-exit / cap / stop-hook pauses, the repeated-tool-failure breaker + // (below) also pauses through it, and it wants to fire on every path + // (including plain channel turns that set none of the other flags). An idle + // handle is a no-op — the loop just drains an empty steering channel. + let handle = Some(SteeringHandle::allow_all()); + + // Repeated-failure circuit breaker: pause the run when a tool returns the same + // error `REPEATED_TOOL_FAILURE_THRESHOLD` times in a row, so a deterministic + // security/approval denial or terminal tool error surfaces its root cause + // instead of burning the whole iteration budget (legacy ProgressGuard parity). + if let Some(handle) = &handle { + harness.push_middleware(Arc::new(middleware::RepeatedToolFailureMiddleware::new( + handle.clone(), + REPEATED_TOOL_FAILURE_THRESHOLD, + ))); + } + + // Policy-driven stop hooks (budget cap, thread-goal budget, ad-hoc iteration + // ceiling): fire after each model call and pause the run on the first stop + // vote. Replaces the legacy tool-call-loop firing point. + if let Some(handle) = &handle { + if !stop_hooks.is_empty() { + harness.push_middleware(Arc::new(stop_hooks::StopHookMiddleware::new( + handle.clone(), + model, + max_iterations, + stop_hooks, + ))); + } + } + let early_exit_set: HashSet<&str> = early_exit_tools.iter().copied().collect(); + // One hook per run, shared by every early-exit adapter (records the first + // early-exit and pauses). Requires the steering handle. + let early_exit_hook = handle + .as_ref() + .filter(|_| !early_exit_set.is_empty()) + .map(|h| EarlyExitHook::new(h.clone())); + + // Register one adapter per unique callable tool name found across the shared + // sets (newest set wins on a name clash; `allowed` empty = all visible). + let mut registered: HashSet = HashSet::new(); + for name in tool_sets + .iter() + .flat_map(|set| set.iter()) + .map(|t| t.name()) + { + if !registered.contains(name) && (allowed.is_empty() || allowed.contains(name)) { + if let Some(mut adapter) = SharedToolAdapter::for_name(tool_sets.clone(), name) { + if early_exit_set.contains(name) { + if let Some(hook) = &early_exit_hook { + adapter = adapter.with_early_exit(hook.clone()); + } + } + registered.insert(name.to_string()); + harness.register_tool(Arc::new(adapter)); + } + } + } + // The unknown-tool sentinel: the model adapter rewrites any unadvertised tool + // call onto it so the run recovers gracefully instead of aborting. Its wording + // matches the legacy engine (sub-agent vs top-level). + harness.register_tool(Arc::new(UnknownToolAdapter::new(subagent_scope.is_some()))); + let tool_count = registered.len(); + + // Human-in-the-loop approval as a named tool middleware (issue #4249, + // Phase 1): an external-effect tool intercepts through the global + // `ApprovalGate`, a denial short-circuits with a model-consumable result, and + // an approved call records a terminal audit row. Replaces the inline approval + // block that used to live in `execute_openhuman_tool`. + harness.push_tool_middleware(Arc::new(middleware::ApprovalSecurityMiddleware::new( + tool_sets.clone(), + ))); + + // Unknown-tool recovery as a `before_tool` middleware (issue #4249, Phase 1 + // Task B): a call to an unadvertised tool is rewritten onto the recovery + // sentinel before the harness resolves it, so a hallucinated tool name is a + // recoverable result rather than a fatal `ToolNotFound`. Replaces the + // `valid_tools` rewrite that used to live in `ProviderModel`. + harness.push_middleware(Arc::new(middleware::UnknownToolRewriteMiddleware::new( + valid_tools, + ))); + + let config = RunConfig::new("agent_turn") + .with_max_model_calls(max_iterations) + .with_max_tool_calls(max_iterations.saturating_mul(8).max(8)); + + tracing::info!( + model, + max_iterations, + tools = tool_count, + observed = on_progress.is_some(), + "[tinyagents] routing turn through tinyagents harness (shared tools)" + ); + + let input = convert::history_to_messages(&history); + + // Build the run context: an optional event sink feeds the progress/cost + // bridge (streaming) and/or the model-call-cap pauser; the shared steering + // handle carries mid-flight, early-exit, and cap pauses. + let mut ctx = RunContext::new(config, ()); + + let streaming = on_progress.is_some(); + // A sink is needed to mirror progress (bridge) or to observe model-call + // completions for the cap pauser. + let events = (on_progress.is_some() || pause_at_cap).then(EventSink::new); + + let bridge = match (&events, on_progress) { + (Some(events), Some(tx)) => { + let bridge = OpenhumanEventBridge::with_scope( + Some(tx), + model, + max_iterations, + subagent_scope.clone(), + cursor.clone(), + ); + events.subscribe(bridge.clone()); + Some(bridge) + } + _ => None, + }; + + // Cap pauser: stop gracefully at the model-call budget (returning the partial + // transcript) so the caller can summarize a checkpoint instead of erroring. + if pause_at_cap { + if let (Some(events), Some(handle)) = (&events, &handle) { + events.subscribe(CapPauser::new(handle.clone(), max_iterations)); + } + } + + if let Some(events) = &events { + ctx = ctx.with_events(events.clone()); + } + + // Steering: attach the shared handle (when present), drain any already-queued + // steer messages into it (so a pre-run steer lands before the first model + // call), and forward mid-flight steers via a poller aborted when the run + // returns. The same handle carries the early-exit `Pause`. + let steering_forwarder = if let Some(handle) = handle { + if let Some(queue) = run_queue.clone() { + forward_steers(&queue, &handle).await; + } + ctx = ctx.with_steering(handle.clone()); + run_queue.map(|queue| { + tokio::spawn(async move { + loop { + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + forward_steers(&queue, &handle).await; + } + }) + }) + } else { + None + }; + + // Heap-allocate the harness drive future. It is large (it owns the whole run + // context, middleware stack, and loop state), and a sub-agent turn runs + // nested inside its parent's drive future — leaving it inline on the stack + // overflows when the parent + child drives compose. Boxing keeps only a + // pointer on the stack at each level. + let run_result = if streaming { + Box::pin(harness.invoke_streaming_in_context(&(), ctx, input)).await + } else { + Box::pin(harness.invoke_in_context(&(), ctx, input)).await + }; + if let Some(forwarder) = steering_forwarder { + forwarder.abort(); + } + let run = match run_result { + Ok(run) => run, + Err(e) => { + // Prefer the original typed provider error (preserves `AgentError` + // downcasts the caller relies on) over the harness's string wrap. + if let Some(original) = error_slot.lock().unwrap().take() { + return Err(original); + } + // The model-call cap (when not pausing gracefully — the channel/CLI + // path) maps to the typed `AgentError::MaxIterationsExceeded` so + // callers downcast it (Sentry skip) and render the canonical + // "Agent exceeded maximum tool iterations" message, matching the + // legacy `ErrorCheckpoint`. + if let tinyagents::TinyAgentsError::LimitExceeded(msg) = &e { + if msg.contains("model call") { + return Err(anyhow::Error::new( + crate::openhuman::agent::error::AgentError::MaxIterationsExceeded { + max: max_iterations, + }, + )); + } + } + return Err(anyhow::anyhow!("tinyagents harness run failed: {e}")); + } + }; + let bridge_totals = bridge.map(|bridge| bridge.totals_with_cost()); + + // Prefer the bridge's accumulated usage (per-call, authoritative — including + // cached tokens and the estimated charged USD) when the observed path ran; + // otherwise fall back to the run's aggregate totals and estimate the cost from + // them so a fire-and-forget turn still reports a real (non-$0) cost. + let (input_tokens, output_tokens, cached_input_tokens, charged_amount_usd) = bridge_totals + .unwrap_or_else(|| { + let input = run.usage.usage.input_tokens; + let output = run.usage.usage.output_tokens; + let cached = run.usage.usage.cache_read_tokens; + let charged = + crate::openhuman::cost::catalog::estimate_cost_usd(model, input, output, cached); + (input, output, cached, charged) + }); + + // An early-exit tool fired: the loop paused after its round. Surface the tool + // name and use its captured question as the turn text (the paused assistant + // turn carries the tool call, not a final answer) so the caller can + // checkpoint and prompt the user — matching the legacy `early_exit_tool`. + let early_exit = early_exit_hook.and_then(|hook| hook.take()); + + // Cap detection: the harness sets `final_response` only when the loop + // finishes naturally (the model stopped requesting tools). When the cap + // pauser stops the loop mid-work, `final_response` stays `None` — that's the + // cap hit. An early-exit is a clean pause and takes precedence; under + // `pause_at_cap` the only other `Pause` source is the cap pauser, so this is + // unambiguous. (`run_queue` steering injects messages, never pauses.) + let hit_cap = pause_at_cap + && early_exit.is_none() + && run.model_calls >= max_iterations + && run.final_response.is_none(); + + let (early_exit_tool, text) = match early_exit { + Some(exit) => (Some(exit.tool), exit.question), + None => (None, run.text().unwrap_or_default()), + }; + + Ok(TinyagentsTurnOutcome { + text, + history: convert::messages_to_history(&run.messages), + conversation: convert::messages_to_conversation(convert::messages_since_last_user( + &run.messages, + )), + model_calls: run.model_calls, + tool_calls: run.tool_calls, + input_tokens, + output_tokens, + cached_input_tokens, + charged_amount_usd, + early_exit_tool, + hit_cap, + }) +} + +#[cfg(test)] +mod tests; diff --git a/src/openhuman/tinyagents/model.rs b/src/openhuman/tinyagents/model.rs new file mode 100644 index 000000000..6ee025433 --- /dev/null +++ b/src/openhuman/tinyagents/model.rs @@ -0,0 +1,479 @@ +//! `tinyagents` [`ChatModel`] adapter over an openhuman [`Provider`] (issue #4249). +//! +//! Wraps `Arc` so the `tinyagents` agent-loop can drive a real +//! openhuman inference backend. On each model call the harness hands us a +//! provider-neutral [`ModelRequest`] (rich messages + advertised tool schemas); +//! we translate it into an openhuman [`ChatRequest`], call `provider.chat`, and +//! translate the [`ChatResponse`] back into a harness [`ModelResponse`] — +//! carrying through text, native tool calls, and token usage. + +use std::sync::atomic::Ordering; +use std::sync::{Arc, Mutex}; + +use async_trait::async_trait; +use tinyagents::harness::message::{AssistantMessage, ContentBlock, MessageDelta}; +use tinyagents::harness::model::{ + ChatModel, ModelRequest, ModelResponse, ModelStream, ModelStreamItem, +}; +use tinyagents::harness::tool::ToolCall as TaToolCall; +use tinyagents::harness::usage::Usage; +use tokio::sync::mpsc::{Sender, UnboundedSender}; + +use super::observability::{IterationCursor, SubagentScope}; +use crate::openhuman::agent::progress::AgentProgress; +use crate::openhuman::inference::provider::{ + ChatMessage, ChatRequest, ChatResponse, Provider, ProviderDelta, +}; +use crate::openhuman::tools::ToolSpec; + +/// Out-of-band forwarder for the streaming progress events that don't round-trip +/// through tinyagents: model reasoning (thinking) deltas and tool-call **argument** +/// deltas. +/// +/// tinyagents' streaming `MessageDelta` carries only assembled visible text — no +/// reasoning channel, and the model adapter assembles tool calls itself rather +/// than streaming their argument fragments through the harness — so the +/// [`OpenhumanEventBridge`](super::OpenhumanEventBridge) can't mirror either. The +/// model adapter is the only seam that sees the provider's +/// [`ProviderDelta::ThinkingDelta`] / [`ProviderDelta::ToolCallArgsDelta`], so it +/// forwards them straight onto the progress sink here, sharing the bridge's +/// [`IterationCursor`] so each delta is attributed to the right model call. +/// Parent runs emit the top-level variants; child runs emit the `Subagent` +/// counterpart for thinking (tool-arg deltas have no child variant, so they ride +/// the top-level event). +#[derive(Clone)] +pub struct ThinkingForwarder { + sink: Sender, + scope: Option, + cursor: IterationCursor, + /// call_id → tool_name, learned from `ToolCallStart`, so an args delta can + /// carry the tool name the UI labels it with. + tool_names: Arc>>, +} + +impl ThinkingForwarder { + pub fn new( + sink: Sender, + scope: Option, + cursor: IterationCursor, + ) -> Self { + Self { + sink, + scope, + cursor, + tool_names: Arc::new(Mutex::new(std::collections::HashMap::new())), + } + } + + /// Best-effort, non-blocking emit of one reasoning chunk (drops on a full + /// channel, matching the streaming text path). + fn emit(&self, delta: String) { + if delta.is_empty() { + return; + } + let iteration = self.cursor.load(Ordering::SeqCst); + let progress = match &self.scope { + None => AgentProgress::ThinkingDelta { delta, iteration }, + Some(s) => AgentProgress::SubagentThinkingDelta { + agent_id: s.agent_id.clone(), + task_id: s.task_id.clone(), + delta, + iteration, + }, + }; + let _ = self.sink.try_send(progress); + } + + /// Record the tool name a streaming tool call starts with, and emit the + /// start marker — an empty-delta `ToolCallArgsDelta` — so consumers see the + /// call begin before its arguments arrive (matching the legacy + /// `ProviderDelta::ToolCallStart` mapping). + fn note_tool_call(&self, call_id: String, tool_name: String) { + self.tool_names + .lock() + .unwrap() + .insert(call_id.clone(), tool_name.clone()); + let _ = self.sink.try_send(AgentProgress::ToolCallArgsDelta { + call_id, + tool_name, + delta: String::new(), + iteration: self.cursor.load(Ordering::SeqCst), + }); + } + + /// Emit one tool-call argument fragment as `ToolCallArgsDelta` so the UI can + /// show the model composing the call before it executes. + fn emit_tool_args(&self, call_id: String, delta: String) { + if delta.is_empty() { + return; + } + let tool_name = self + .tool_names + .lock() + .unwrap() + .get(&call_id) + .cloned() + .unwrap_or_default(); + let _ = self.sink.try_send(AgentProgress::ToolCallArgsDelta { + call_id, + tool_name, + delta, + iteration: self.cursor.load(Ordering::SeqCst), + }); + } +} + +/// Translate a harness [`ModelRequest`] into openhuman's message list + tool +/// specs (shared by the buffered and streaming paths). +fn build_chat_inputs( + request: &ModelRequest, + native_tools: bool, +) -> (Vec, Vec) { + // Native-tool providers need assistant tool calls + tool results encoded in + // the provider's native envelope so a tool round round-trips; prompt-guided + // providers need tool results folded into a `[Tool results]` user turn. + let messages = if native_tools { + request + .messages + .iter() + .map(super::convert::message_to_native_chat_message) + .collect() + } else { + super::convert::messages_to_text_mode_chat(&request.messages) + }; + let specs = request + .tools + .iter() + .map(|s| ToolSpec { + name: s.name.clone(), + description: s.description.clone(), + parameters: s.parameters.clone(), + }) + .collect(); + (messages, specs) +} + +/// Translate an openhuman [`ChatResponse`] into a harness [`ModelResponse`] +/// (visible text + tool calls + token usage). +/// +/// Native `tool_calls` take precedence; when absent, the response text is parsed +/// for prompt-guided (`…` / p-format) calls — matching the legacy +/// dispatcher — so text-mode models drive the tinyagents loop too. The visible +/// text is the prose with any tool-call markup stripped. +/// +/// Rewriting a hallucinated/unadvertised tool call onto the recovery sentinel +/// now happens at the tool boundary in +/// [`UnknownToolRewriteMiddleware`](super::middleware) (`before_tool`), not here. +fn response_to_model_response(response: &ChatResponse) -> ModelResponse { + let (visible_text, tool_calls): (String, Vec) = if !response.tool_calls.is_empty() { + let calls = response + .tool_calls + .iter() + .map(|tc| TaToolCall { + id: tc.id.clone(), + name: tc.name.clone(), + arguments: serde_json::from_str(&tc.arguments).unwrap_or(serde_json::Value::Null), + }) + .collect(); + (response.text.clone().unwrap_or_default(), calls) + } else if let Some(text) = response.text.as_deref() { + let (prose, parsed) = crate::openhuman::agent::harness::parse_tool_calls(text); + if parsed.is_empty() { + (text.to_string(), Vec::new()) + } else { + let calls = parsed + .into_iter() + .enumerate() + .map(|(i, p)| TaToolCall { + // Prompt-guided calls carry no provider id; synthesize a + // stable one so tool results correlate in the harness. + id: p.id.unwrap_or_else(|| format!("call_{i}")), + name: p.name, + arguments: p.arguments, + }) + .collect(); + (prose, calls) + } + } else { + (String::new(), Vec::new()) + }; + + let mut content = Vec::new(); + if !visible_text.is_empty() { + content.push(ContentBlock::Text(visible_text)); + } + let usage = response.usage.as_ref().map(|u| { + // Carry the provider's cached-prefix input count through the crate + // `Usage` (it has a `cache_read_tokens` field) so downstream cost + // accounting can price it at the cached rate. `Usage::new` seeds + // input/output/total; set the cache field on top. (`charged_amount_usd` + // has no crate home; the event bridge estimates cost from token counts.) + let mut usage = Usage::new(u.input_tokens, u.output_tokens); + usage.cache_read_tokens = u.cached_input_tokens; + usage + }); + let finish_reason = if tool_calls.is_empty() { + "stop" + } else { + "tool_calls" + }; + ModelResponse { + message: AssistantMessage { + id: None, + content, + tool_calls, + usage, + }, + usage, + finish_reason: Some(finish_reason.to_string()), + raw: None, + resolved_model: None, + } +} + +/// Forward one openhuman [`ProviderDelta`]. Visible text becomes a harness +/// [`MessageDelta`] (so the bridge mirrors it as a text delta); reasoning and +/// tool-call **argument** fragments ride the out-of-band [`ThinkingForwarder`] +/// (the harness stream carries neither). The model adapter still assembles the +/// final native tool calls from the `Completed` response — these fragments are +/// progress-only, so the UI can show the call being composed. +fn forward_delta( + tx: &UnboundedSender, + thinking: Option<&ThinkingForwarder>, + delta: ProviderDelta, +) { + match delta { + ProviderDelta::TextDelta { delta } => { + if !delta.is_empty() { + // `MessageDelta::text` sets the visible-text fragment and defaults + // the `reasoning` (new in tinyagents 1.2.0) and `tool_call` fields. + // Reasoning still rides the out-of-band `ThinkingForwarder` below + // (see `ThinkingDelta`) rather than the native `reasoning` channel, + // preserving the existing subagent-scoped thinking UI wiring. + let _ = tx.send(ModelStreamItem::MessageDelta(MessageDelta::text(delta))); + } + } + ProviderDelta::ThinkingDelta { delta } => { + if let Some(forwarder) = thinking { + forwarder.emit(delta); + } + } + ProviderDelta::ToolCallStart { call_id, tool_name } => { + if let Some(forwarder) = thinking { + forwarder.note_tool_call(call_id, tool_name); + } + } + ProviderDelta::ToolCallArgsDelta { call_id, delta } => { + if let Some(forwarder) = thinking { + forwarder.emit_tool_args(call_id, delta); + } + } + } +} + +/// A harness chat model backed by an openhuman [`Provider`]. +/// +/// The application `State` is `()` — openhuman tools and providers carry no +/// harness-visible shared state — so this adapter implements +/// `ChatModel<()>`. +/// Shared slot that preserves the most recent original provider error. +/// +/// tinyagents carries errors as `TinyAgentsError::Model(String)`, which would +/// stringify openhuman's typed `anyhow::Error` (e.g. `AgentError::PermissionDenied` +/// / `MaxIterationsExceeded`) and break the downcast the caller relies on for +/// Sentry suppression and `AgentError`-tagged events. The adapter stashes the +/// original error here before returning the stringified one to the harness, so +/// the runner can re-surface the downcastable error after the run fails. +pub type ProviderErrorSlot = Arc>>; + +pub struct ProviderModel { + provider: Arc, + model: String, + temperature: f64, + max_tokens: Option, + /// When set, the adapter forwards provider reasoning deltas onto the + /// progress sink (the harness stream has no reasoning channel). + thinking: Option, + /// Preserves the last original provider error for the runner to re-surface. + error_slot: ProviderErrorSlot, +} + +impl ProviderModel { + /// Build a model adapter for `provider`, pinned to `model`/`temperature`. + pub fn new(provider: Arc, model: impl Into, temperature: f64) -> Self { + Self { + provider, + model: model.into(), + temperature, + max_tokens: None, + thinking: None, + error_slot: Arc::new(Mutex::new(None)), + } + } + + /// A handle to the shared error slot (clone before moving `self` into the + /// harness, so the runner can recover the typed provider error on failure). + pub fn error_slot(&self) -> ProviderErrorSlot { + self.error_slot.clone() + } + + /// Cap the output tokens requested from the provider for every call. + pub fn with_max_tokens(mut self, max_tokens: u32) -> Self { + self.max_tokens = Some(max_tokens); + self + } + + /// Forward provider reasoning / thinking deltas onto a progress sink via + /// `forwarder` (parent or sub-agent scoped). See [`ThinkingForwarder`]. + pub fn with_thinking(mut self, forwarder: ThinkingForwarder) -> Self { + self.thinking = Some(forwarder); + self + } +} + +#[async_trait] +impl ChatModel<()> for ProviderModel { + async fn invoke( + &self, + _state: &(), + request: ModelRequest, + ) -> tinyagents::Result { + let native = self.provider.supports_native_tools(); + let (messages, specs) = build_chat_inputs(&request, native); + let chat_request = ChatRequest { + messages: &messages, + // Only advertise structured tool specs to native providers. Prompt- + // guided providers (Ollama/LM Studio profiles) get the tool catalogue + // folded into the transcript instead; sending a `tools`/`tool_choice` + // payload would defeat the opt-out and get rejected/ignored. + tools: (native && !specs.is_empty()).then_some(&specs), + stream: None, + max_tokens: self.max_tokens, + }; + + tracing::debug!( + model = %self.model, + messages = messages.len(), + tools = specs.len(), + "[tinyagents] provider.chat via harness model adapter" + ); + + let response = match self + .provider + .chat(chat_request, &self.model, self.temperature) + .await + { + Ok(response) => response, + Err(e) => { + // Preserve the original (downcastable) error for the runner, then + // hand the harness a stringified copy to stop the loop. + let msg = format!("openhuman provider chat failed: {e}"); + *self.error_slot.lock().unwrap() = Some(e); + return Err(tinyagents::TinyAgentsError::Model(msg)); + } + }; + // Non-streaming path: surface any reasoning the provider returned as a + // single post-hoc thinking delta (it had no per-token channel to ride). + if let Some(forwarder) = &self.thinking { + if let Some(reasoning) = response + .reasoning_content + .as_ref() + .filter(|r| !r.is_empty()) + { + forwarder.emit(reasoning.clone()); + } + } + Ok(response_to_model_response(&response)) + } + + /// Stream the model response, forwarding openhuman's `ProviderDelta` events + /// as harness [`ModelStreamItem`]s so the agent loop emits live `ModelDelta` + /// events (which the [`OpenhumanEventBridge`](super::OpenhumanEventBridge) + /// mirrors onto `AgentProgress` text deltas). + /// + /// A streaming-capable provider forwards incremental text to the + /// per-call delta channel; a non-streaming provider simply returns the + /// aggregated response, which still arrives as the terminal `Completed` + /// item. Native tool calls always ride on `Completed`. + async fn stream(&self, _state: &(), request: ModelRequest) -> tinyagents::Result { + let native = self.provider.supports_native_tools(); + let (messages, specs) = build_chat_inputs(&request, native); + let provider = self.provider.clone(); + let model = self.model.clone(); + let temperature = self.temperature; + let max_tokens = self.max_tokens; + let thinking = self.thinking.clone(); + let error_slot = self.error_slot.clone(); + + let (item_tx, item_rx) = tokio::sync::mpsc::unbounded_channel::(); + + // Producer: run the provider call while forwarding its incremental + // deltas, then emit the terminal item. Everything captured is owned, so + // the task is `'static`. + tokio::spawn(async move { + let _ = item_tx.send(ModelStreamItem::Started); + let (delta_tx, mut delta_rx) = tokio::sync::mpsc::channel::(64); + let chat_fut = async { + let req = ChatRequest { + messages: &messages, + // Prompt-guided providers get the tool catalogue in the + // transcript, not a structured `tools` payload (see the + // buffered path). `native` is captured by the async move. + tools: (native && !specs.is_empty()).then_some(&specs), + stream: Some(&delta_tx), + max_tokens, + }; + provider.chat(req, &model, temperature).await + }; + tokio::pin!(chat_fut); + + let mut streamed_thinking = false; + let response = loop { + tokio::select! { + maybe = delta_rx.recv() => { + if let Some(delta) = maybe { + streamed_thinking |= matches!(delta, ProviderDelta::ThinkingDelta { .. }); + forward_delta(&item_tx, thinking.as_ref(), delta); + } + } + res = &mut chat_fut => break res, + } + }; + // Drain any deltas that landed before the call returned. + while let Ok(delta) = delta_rx.try_recv() { + streamed_thinking |= matches!(delta, ProviderDelta::ThinkingDelta { .. }); + forward_delta(&item_tx, thinking.as_ref(), delta); + } + + let terminal = match response { + Ok(resp) => { + // Fallback for providers that return reasoning only on the + // aggregated response (no incremental thinking deltas): emit + // it once so child/parent thinking output isn't lost. + if !streamed_thinking { + if let Some(forwarder) = &thinking { + if let Some(reasoning) = + resp.reasoning_content.as_ref().filter(|r| !r.is_empty()) + { + forwarder.emit(reasoning.clone()); + } + } + } + ModelStreamItem::Completed(response_to_model_response(&resp)) + } + Err(e) => { + // Preserve the original (downcastable) error for the runner. + let msg = format!("openhuman provider chat failed: {e}"); + *error_slot.lock().unwrap() = Some(e); + ModelStreamItem::Failed(msg) + } + }; + let _ = item_tx.send(terminal); + }); + + let stream = futures_util::stream::unfold(item_rx, |mut rx| async move { + rx.recv().await.map(|item| (item, rx)) + }); + Ok(Box::pin(stream)) + } +} diff --git a/src/openhuman/tinyagents/observability.rs b/src/openhuman/tinyagents/observability.rs new file mode 100644 index 000000000..49df8652d --- /dev/null +++ b/src/openhuman/tinyagents/observability.rs @@ -0,0 +1,421 @@ +//! Bridge the `tinyagents` harness event stream onto openhuman's +//! [`AgentProgress`] + cost tracker (issue #4249, tinyagents 0.2.0). +//! +//! 0.2.0 emits a typed [`AgentEvent`] stream (model started/delta/completed, +//! tool started/completed, usage) through an [`EventSink`] that callers attach +//! to a [`RunContext`]. This listener translates those into the same +//! `AgentProgress` events the legacy `run_turn_engine` produced — restoring the +//! live tool timeline, streaming text, and the cost/token footer on the +//! tinyagents path — and feeds per-call usage into the global cost tracker. + +use std::sync::atomic::{AtomicU32, Ordering}; +use std::sync::{Arc, Mutex}; + +use tokio::sync::mpsc::Sender; + +use tinyagents::graph::stream::{GraphEvent, GraphEventSink}; +use tinyagents::harness::events::{AgentEvent, EventListener, EventRecord}; +use tinyagents::harness::steering::{SteeringCommand, SteeringHandle}; +use tinyagents::harness::usage::Usage; + +use crate::openhuman::agent::progress::AgentProgress; +use crate::openhuman::inference::provider::UsageInfo; +use crate::openhuman::tools::traits::humanize_tool_name; + +/// Attribution for child (sub-agent) progress. When present, the bridge routes +/// events to the `Subagent*` [`AgentProgress`] variants (so the parent thread +/// can nest child activity under a live subagent row) instead of the top-level +/// ones. Absent = a parent/top-level turn. +#[derive(Clone)] +pub struct SubagentScope { + pub agent_id: String, + pub task_id: String, + pub extended_policy: bool, +} + +/// A shared 1-based model-call (iteration) cursor. The bridge advances it on +/// each `ModelStarted` event; the model adapter reads it to attribute the +/// thinking deltas it forwards out-of-band (tinyagents 0.2.0's `MessageDelta` +/// carries no reasoning channel, so reasoning can't ride the harness stream). +pub type IterationCursor = Arc; + +/// An [`EventListener`] that pauses the run once `cap` model calls have +/// completed, so the loop stops gracefully at the iteration budget (returning +/// the partial transcript) instead of erroring with `LimitExceeded`. The harness +/// checks pending steering at the top of each turn *before* the model-call limit +/// check, so a `Pause` sent here short-circuits the loop cleanly. The caller then +/// inspects the run's finish reason to decide whether to summarize a checkpoint +/// — the tinyagents analogue of the legacy `CheckpointStrategy::on_max_iter`. +pub struct CapPauser { + handle: SteeringHandle, + cap: u32, + completed: AtomicU32, +} + +impl CapPauser { + /// Pause `handle` once `cap` model calls complete. + pub fn new(handle: SteeringHandle, cap: usize) -> Arc { + Arc::new(Self { + handle, + cap: cap as u32, + completed: AtomicU32::new(0), + }) + } +} + +impl EventListener for CapPauser { + fn on_event(&self, record: &EventRecord) { + if matches!(record.event, AgentEvent::ModelCompleted { .. }) { + let n = self.completed.fetch_add(1, Ordering::SeqCst) + 1; + if n >= self.cap { + tracing::info!( + completed = n, + cap = self.cap, + "[tinyagents] model-call cap reached — requesting graceful pause" + ); + self.handle.send(SteeringCommand::Pause); + } + } + } +} + +#[derive(Default)] +struct BridgeState { + input_tokens: u64, + output_tokens: u64, + cached_input_tokens: u64, + charged_amount_usd: f64, +} + +/// An [`EventListener`] that mirrors harness events onto openhuman's progress +/// sink and cost tracker. +pub struct OpenhumanEventBridge { + on_progress: Option>, + model: String, + max_iterations: u32, + /// `None` for a parent turn; `Some` to emit child-scoped `Subagent*` events. + scope: Option, + /// Shared with the model adapter so thinking deltas line up with the + /// model call (iteration) they belong to. + cursor: IterationCursor, + state: Mutex, +} + +impl OpenhumanEventBridge { + /// Build a parent-scoped bridge for `model`. + pub fn new( + on_progress: Option>, + model: impl Into, + max_iterations: usize, + ) -> Arc { + Self::with_scope(on_progress, model, max_iterations, None, Arc::default()) + } + + /// Build a bridge, optionally child-scoped, sharing `cursor` with the model + /// adapter so out-of-band thinking deltas carry the same iteration index. + pub fn with_scope( + on_progress: Option>, + model: impl Into, + max_iterations: usize, + scope: Option, + cursor: IterationCursor, + ) -> Arc { + Arc::new(Self { + on_progress, + model: model.into(), + max_iterations: max_iterations as u32, + scope, + cursor, + state: Mutex::new(BridgeState::default()), + }) + } + + /// Cumulative `(input_tokens, output_tokens, charged_usd)` observed so far. + pub fn totals(&self) -> (u64, u64, f64) { + let s = self.state.lock().unwrap(); + (s.input_tokens, s.output_tokens, s.charged_amount_usd) + } + + /// Cumulative `(input_tokens, output_tokens, cached_input_tokens, charged_usd)` + /// observed so far — the full accounting the turn persists (transcript cost / + /// session meters), so a normal turn no longer records `$0` and zero cached + /// tokens despite real usage. + pub fn totals_with_cost(&self) -> (u64, u64, u64, f64) { + let s = self.state.lock().unwrap(); + ( + s.input_tokens, + s.output_tokens, + s.cached_input_tokens, + s.charged_amount_usd, + ) + } + + /// Best-effort, non-blocking progress emit (drops on a full channel, like + /// the legacy streaming path). + fn send(&self, progress: AgentProgress) { + if let Some(tx) = &self.on_progress { + let _ = tx.try_send(progress); + } + } + + fn iteration(&self) -> u32 { + self.cursor.load(Ordering::SeqCst) + } + + /// Accumulate a usage block, feed the global cost tracker, and emit a + /// `TurnCostUpdated` so the UI footer stays live. + fn record_usage(&self, usage: &Usage) { + let iteration = self.iteration(); + // Provider-reported charged USD has no home in the crate `Usage` (all + // token counts), so estimate this call's cost from catalogued per-MTok + // rates. Fixes the long-standing $0 cost on the tinyagents path, where + // the charged amount was hardcoded to 0.0 (issue #4249, Phase 5). When a + // provider genuinely charges (credit-metered backends) preserving that + // exact amount needs an out-of-band carry — tracked as a follow-up. + let call_cost = crate::openhuman::cost::catalog::estimate_cost_usd( + &self.model, + usage.input_tokens, + usage.output_tokens, + usage.cache_read_tokens, + ); + let (input, output, cached, charged) = { + let mut s = self.state.lock().unwrap(); + s.input_tokens += usage.input_tokens; + s.output_tokens += usage.output_tokens; + s.cached_input_tokens += usage.cache_read_tokens; + s.charged_amount_usd += call_cost; + ( + s.input_tokens, + s.output_tokens, + s.cached_input_tokens, + s.charged_amount_usd, + ) + }; + + // Feed the authoritative global cost tracker (same call the legacy + // observer made), so the wallet/cost surfaces stay accurate. + let usage_info = UsageInfo { + input_tokens: usage.input_tokens, + output_tokens: usage.output_tokens, + context_window: 0, + cached_input_tokens: usage.cache_read_tokens, + charged_amount_usd: call_cost, + }; + crate::openhuman::cost::record_provider_usage(&self.model, &usage_info); + + // The cost footer is a top-level surface; for a child run the global + // cost tracker feed above is the authoritative accounting and the parent + // emits its own footer, so suppress the per-child `TurnCostUpdated`. + if self.scope.is_none() { + self.send(AgentProgress::TurnCostUpdated { + model: self.model.clone(), + iteration, + input_tokens: input, + output_tokens: output, + cached_input_tokens: cached, + total_usd: charged, + }); + } + } +} + +impl EventListener for OpenhumanEventBridge { + fn on_event(&self, record: &EventRecord) { + match &record.event { + AgentEvent::ModelStarted { .. } => { + let iteration = self.cursor.fetch_add(1, Ordering::SeqCst) + 1; + match &self.scope { + None => self.send(AgentProgress::IterationStarted { + iteration, + max_iterations: self.max_iterations, + }), + Some(s) => self.send(AgentProgress::SubagentIterationStarted { + agent_id: s.agent_id.clone(), + task_id: s.task_id.clone(), + iteration, + max_iterations: self.max_iterations, + extended_policy: s.extended_policy, + }), + } + } + AgentEvent::ModelDelta { delta, .. } => { + if !delta.text.is_empty() { + let iteration = self.iteration(); + match &self.scope { + None => self.send(AgentProgress::TextDelta { + delta: delta.text.clone(), + iteration, + }), + Some(s) => self.send(AgentProgress::SubagentTextDelta { + agent_id: s.agent_id.clone(), + task_id: s.task_id.clone(), + delta: delta.text.clone(), + iteration, + }), + } + } + } + // `UsageRecorded` carries the authoritative per-call usage and fires + // exactly once per model call; prefer it over `ModelCompleted`'s + // optional usage to avoid double counting. + AgentEvent::UsageRecorded { usage } => self.record_usage(usage), + AgentEvent::ToolStarted { call_id, tool_name } => { + let iteration = self.iteration(); + match &self.scope { + None => self.send(AgentProgress::ToolCallStarted { + call_id: call_id.as_str().to_string(), + tool_name: tool_name.clone(), + arguments: serde_json::Value::Null, + iteration, + display_label: Some(humanize_tool_name(tool_name)), + display_detail: None, + }), + Some(s) => self.send(AgentProgress::SubagentToolCallStarted { + agent_id: s.agent_id.clone(), + task_id: s.task_id.clone(), + call_id: call_id.as_str().to_string(), + tool_name: tool_name.clone(), + arguments: serde_json::Value::Null, + iteration, + display_label: Some(humanize_tool_name(tool_name)), + display_detail: None, + }), + } + } + AgentEvent::ToolCompleted { call_id, tool_name } => { + let iteration = self.iteration(); + match &self.scope { + None => self.send(AgentProgress::ToolCallCompleted { + call_id: call_id.as_str().to_string(), + tool_name: tool_name.clone(), + success: true, + output_chars: 0, + elapsed_ms: 0, + iteration, + }), + Some(s) => self.send(AgentProgress::SubagentToolCallCompleted { + agent_id: s.agent_id.clone(), + task_id: s.task_id.clone(), + call_id: call_id.as_str().to_string(), + tool_name: tool_name.clone(), + success: true, + output_chars: 0, + output: String::new(), + elapsed_ms: 0, + iteration, + }), + } + } + _ => {} + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tinyagents::harness::events::EventSink; + + #[tokio::test] + async fn bridge_forwards_tool_and_cost_progress() { + let (tx, mut rx) = tokio::sync::mpsc::channel(64); + let bridge = OpenhumanEventBridge::new(Some(tx), "mock-model", 10); + let sink = EventSink::new(); + sink.subscribe(bridge.clone()); + + sink.emit(AgentEvent::ModelStarted { + call_id: "c1".into(), + model: "mock-model".to_string(), + }); + sink.emit(AgentEvent::ToolStarted { + call_id: "c1".into(), + tool_name: "echo".to_string(), + }); + sink.emit(AgentEvent::ToolCompleted { + call_id: "c1".into(), + tool_name: "echo".to_string(), + }); + sink.emit(AgentEvent::UsageRecorded { + usage: Usage::new(100, 40), + }); + + let mut kinds = Vec::new(); + while let Ok(p) = rx.try_recv() { + kinds.push(match p { + AgentProgress::IterationStarted { .. } => "iter", + AgentProgress::ToolCallStarted { .. } => "tool_start", + AgentProgress::ToolCallCompleted { .. } => "tool_done", + AgentProgress::TurnCostUpdated { input_tokens, .. } => { + assert_eq!(input_tokens, 100); + "cost" + } + _ => "other", + }); + } + assert!(kinds.contains(&"iter")); + assert!(kinds.contains(&"tool_start")); + assert!(kinds.contains(&"tool_done")); + assert!(kinds.contains(&"cost")); + + let (input, output, _) = bridge.totals(); + assert_eq!((input, output), (100, 40)); + } +} + +/// A [`GraphEventSink`] that mirrors the `tinyagents` graph executor's lifecycle +/// stream onto openhuman's `tracing` diagnostics — an observability journal for +/// graph runs (issue #4249 / #28). Node/step/run/route transitions land as +/// grep-friendly `[graph]` lines tagged with `label`; the running event count is +/// exposed for tests. Shared by every openhuman graph (council fan-out, +/// sub-agent delegation, …). +pub struct GraphTracingSink { + label: String, + count: Arc, +} + +impl GraphTracingSink { + /// Build a sink tagging its lines with `label` (e.g. `"delegation:graph"`). + /// Accepts both string literals and runtime-built labels. + pub fn new(label: impl Into) -> Self { + Self { + label: label.into(), + count: Arc::new(std::sync::atomic::AtomicUsize::new(0)), + } + } + + /// Shared counter of events observed, for assertions. + pub fn counter(&self) -> Arc { + self.count.clone() + } +} + +impl GraphEventSink for GraphTracingSink { + fn emit(&self, event: GraphEvent) { + self.count.fetch_add(1, Ordering::Relaxed); + let label = self.label.as_str(); + match &event { + GraphEvent::RunStarted { run_id } => { + tracing::debug!(label, ?run_id, "[graph] run started") + } + GraphEvent::RunCompleted { steps, .. } => { + tracing::debug!(label, steps, "[graph] run completed") + } + GraphEvent::RunFailed { error, .. } => { + tracing::warn!(label, %error, "[graph] run failed") + } + GraphEvent::NodeStarted { node, step } => { + tracing::debug!(label, ?node, step, "[graph] node started") + } + GraphEvent::NodeCompleted { node, step } => { + tracing::debug!(label, ?node, step, "[graph] node completed") + } + GraphEvent::NodeFailed { node, error, .. } => { + tracing::warn!(label, ?node, %error, "[graph] node failed") + } + GraphEvent::RouteSelected { node, target } => { + tracing::trace!(label, ?node, ?target, "[graph] route selected") + } + _ => tracing::trace!(label, "[graph] event"), + } + } +} diff --git a/src/openhuman/tinyagents/orchestration.rs b/src/openhuman/tinyagents/orchestration.rs new file mode 100644 index 000000000..465337b87 --- /dev/null +++ b/src/openhuman/tinyagents/orchestration.rs @@ -0,0 +1,261 @@ +//! Shared orchestration helpers on the `tinyagents` graph layer (issue #4249). +//! +//! openhuman's control plane historically hand-rolled fan-out +//! ([`futures_util::future::join_all`]) and a bespoke detached-sub-agent registry +//! (raw `tokio` `AbortHandle`s, `watch` status channels, tombstone sets). This +//! module is the shared seam that re-expresses that work on `tinyagents` +//! primitives so every orchestration surface (workflow phases, parallel agents, +//! teams, detached sub-agents) routes through one place: +//! +//! - [`run_parallel_fanout`] builds a real [`CompiledGraph`] map step — a +//! command-routing `dispatch` entry that fans out to N worker nodes running in +//! the same super-step (`with_parallel` + `with_max_concurrency`), each writing +//! its result into typed graph state through the reducer, joined at a `collect` +//! barrier. Results come back in input order regardless of completion order. +//! - The `graph::orchestration` task primitives ([`TaskStore`], +//! [`OrchestrationTaskKind`], …) are re-exported here so the detached-sub-agent +//! control plane gets typed task lifecycle bookkeeping (Pending → Running → +//! Completed/Failed/Cancelled/…) instead of bespoke status enums + watch +//! channels + tombstones. The store tracks lifecycle; the caller still owns the +//! executor (the `tokio` task + cooperative cancel + hard abort). +//! +//! Graph lifecycle events are mirrored onto tracing via the shared +//! [`GraphTracingSink`](crate::openhuman::tinyagents::observability::GraphTracingSink). + +use std::future::Future; +use std::sync::{Arc, Mutex as StdMutex}; + +use tinyagents::graph::{ClosureStateReducer, Command, GraphBuilder, NodeContext, NodeResult}; + +use crate::openhuman::tinyagents::observability::GraphTracingSink; + +// Re-export the tinyagents task-orchestration primitives so the detached +// sub-agent control plane imports lifecycle types from one openhuman path. +pub use tinyagents::graph::orchestration::{ + InMemoryTaskStore, OrchestrationControlOutcome, OrchestrationTaskFilter, OrchestrationTaskKind, + OrchestrationTaskRecord, OrchestrationTaskResult, OrchestrationTaskSpec, + OrchestrationTaskStatus, TaskStore, +}; + +/// Typed working state for a parallel fan-out: one result slot per worker, +/// filled by the reducer as each worker node completes in any order. +#[derive(Clone)] +struct FanoutState { + slots: Vec>, +} + +// Manual `Default` (derive would demand `T: Default`, which workers don't have). +impl Default for FanoutState { + fn default() -> Self { + Self { slots: Vec::new() } + } +} + +/// Reducer update emitted by a fan-out worker node. +enum FanoutUpdate { + /// Worker `index` finished; store its result. + Slot { index: usize, value: Box }, + /// The `collect` fan-in barrier fired; carries no state change. + Noop, +} + +/// Run `run_one(index, item)` for every entry in `items` concurrently on a +/// `tinyagents` fan-out graph, returning the results in **input order** +/// (regardless of completion order). `max_concurrency` bounds how many workers +/// run in the shared super-step. +/// +/// This is the generic engine behind the council member fan-out and the +/// `spawn_parallel_agents` tool: pure graph mechanics with no domain knowledge, +/// so it is unit-testable with a trivial closure. +/// +/// `label` names the fan-out for tracing. Each `item` is moved into its own +/// worker node (no `Clone` bound on the payload). The worker output `T` must be +/// `Clone` (it rides in the graph's typed state). +pub async fn run_parallel_fanout( + label: &str, + items: Vec, + max_concurrency: usize, + run_one: F, +) -> Result, String> +where + I: Send + 'static, + T: Clone + Send + Sync + 'static, + F: Fn(usize, I) -> Fut + Clone + Send + Sync + 'static, + Fut: Future + Send + 'static, +{ + let n = items.len(); + if n == 0 { + return Ok(Vec::new()); + } + let worker_ids: Vec = (0..n).map(|i| format!("worker_{i}")).collect(); + + let mut builder = GraphBuilder::, FanoutUpdate>::new() + .with_parallel(true) + .with_max_concurrency(max_concurrency.max(1)) + .set_reducer(ClosureStateReducer::new( + |mut s: FanoutState, u: FanoutUpdate| { + if let FanoutUpdate::Slot { index, value } = u { + if let Some(slot) = s.slots.get_mut(index) { + *slot = Some(*value); + } + } + Ok(s) + }, + )); + + // `dispatch`: command-routing entry that fans out to every worker node. + let goto_ids = worker_ids.clone(); + builder = builder.add_node("dispatch", move |_s: FanoutState, _c: NodeContext| { + let goto_ids = goto_ids.clone(); + async move { Ok(NodeResult::Command(Command::default().with_goto(goto_ids))) } + }); + + // One node per worker: runs `run_one(i, item)` and writes the result into + // its slot. The graph's `NodeHandler` is `Fn` (re-entrant), but each node + // runs exactly once — hold the moved-in payload in a take-once cell so it is + // consumed without a `Clone` bound on `I`. + for (i, item) in items.into_iter().enumerate() { + let run_one = run_one.clone(); + let node_id = worker_ids[i].clone(); + let cell = Arc::new(StdMutex::new(Some(item))); + builder = builder.add_node( + node_id.clone(), + move |_s: FanoutState, _c: NodeContext| { + let run_one = run_one.clone(); + let cell = cell.clone(); + async move { + let item = cell + .lock() + .expect("fan-out worker cell poisoned") + .take() + .expect("fan-out worker node ran more than once"); + let value = run_one(i, item).await; + Ok(NodeResult::Update(FanoutUpdate::Slot { + index: i, + value: Box::new(value), + })) + } + }, + ); + builder = builder.add_edge(node_id, "collect"); + } + + // `collect`: fan-in barrier the executor schedules only once every worker + // edge fired. Leaves accumulated state untouched and finishes the graph. + builder = builder + .add_node( + "collect", + |_s: FanoutState, _c: NodeContext| async move { + Ok(NodeResult::Update(FanoutUpdate::Noop)) + }, + ) + .set_entry("dispatch") + .mark_command_routing("dispatch") + .set_finish("collect"); + + let graph = builder + .compile() + .map_err(|e| format!("{label} fan-out graph compile failed: {e}"))? + .with_event_sink(Arc::new(GraphTracingSink::new(label))); + + tracing::debug!( + target: "orchestration", + workers = n, + max_concurrency = max_concurrency.max(1), + "[orchestration] running parallel fan-out on tinyagents graph ({label})" + ); + + let execution = graph + .run(FanoutState { + slots: vec![None; n], + }) + .await + .map_err(|e| format!("{label} fan-out graph run failed: {e}"))?; + + // Every worker node has an edge the executor must traverse, so every slot is + // populated; a missing slot is a hard invariant break. + let mut out = Vec::with_capacity(n); + for (i, slot) in execution.state.slots.into_iter().enumerate() { + match slot { + Some(v) => out.push(v), + None => { + return Err(format!( + "{label} fan-out: worker {i} produced no result (graph invariant broken)" + )) + } + } + } + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicUsize, Ordering}; + + #[tokio::test] + async fn fanout_runs_every_worker_and_preserves_input_order() { + let labels = vec!["a".to_string(), "b".to_string(), "c".to_string()]; + let ran = Arc::new(AtomicUsize::new(0)); + let ran2 = ran.clone(); + let results = run_parallel_fanout("test", labels, 4, move |i, label| { + let ran = ran2.clone(); + async move { + ran.fetch_add(1, Ordering::SeqCst); + format!("{i}:{label}") + } + }) + .await + .expect("fan-out runs"); + + assert_eq!(ran.load(Ordering::SeqCst), 3, "every worker ran once"); + assert_eq!(results, vec!["0:a", "1:b", "2:c"], "results in input order"); + } + + #[tokio::test] + async fn fanout_empty_is_a_noop() { + let results = + run_parallel_fanout::("empty", vec![], 4, |_, _| async move { + String::new() + }) + .await + .expect("empty fan-out runs"); + assert!(results.is_empty()); + } + + #[tokio::test] + async fn fanout_handles_single_worker() { + let results = + run_parallel_fanout("solo", vec!["x".to_string()], 1, |_, label| async move { + format!("only:{label}") + }) + .await + .expect("single-worker fan-out runs"); + assert_eq!(results, vec!["only:x"]); + } + + #[tokio::test] + async fn task_store_tracks_lifecycle() { + // Smoke the re-exported orchestration primitives: a task moves + // Pending → Running → Completed and is readable back by id. + let store = InMemoryTaskStore::new(); + let spec = OrchestrationTaskSpec::new( + "task-1", + OrchestrationTaskKind::SubAgent { + agent: "researcher".to_string(), + }, + ); + let rec = store.insert(spec).expect("insert"); + assert_eq!(rec.status, OrchestrationTaskStatus::Pending); + + store.mark_running(rec.task_id()).expect("running"); + let done = store + .complete(rec.task_id(), OrchestrationTaskResult::text("done")) + .expect("complete"); + assert_eq!(done.status, OrchestrationTaskStatus::Completed); + assert_eq!( + store.get(rec.task_id()).map(|r| r.status), + Some(OrchestrationTaskStatus::Completed) + ); + } +} diff --git a/src/openhuman/tinyagents/stop_hooks.rs b/src/openhuman/tinyagents/stop_hooks.rs new file mode 100644 index 000000000..7fc15bef1 --- /dev/null +++ b/src/openhuman/tinyagents/stop_hooks.rs @@ -0,0 +1,149 @@ +//! Mid-turn stop hooks as a `tinyagents` middleware (issue #4249). +//! +//! openhuman's policy-driven [`StopHook`]s (budget cap, thread-goal budget, +//! ad-hoc iteration ceiling) used to fire between iterations of the legacy +//! tool-call loop. That loop is gone — every turn now runs on the `tinyagents` +//! harness — so the firing point moves into a [`Middleware`]: +//! +//! - [`Middleware::after_model`] accumulates each completed model call's usage +//! into a per-turn [`TurnCost`] (the same accounting the hooks read), then +//! evaluates every installed hook with a [`TurnState`] snapshot. +//! - On the first [`StopDecision::Stop`], it sends [`SteeringCommand::Pause`] on +//! the run's steering handle. The agent loop drains steering at the **top** of +//! the next iteration (before the next model call) and `Pause` short-circuits +//! it cleanly — so the run stops before spending another call, returning the +//! partial transcript. This mirrors the [`CapPauser`](super::CapPauser) +//! model-call-cap mechanism. +//! +//! The hook list is captured by the caller via +//! [`current_stop_hooks`](crate::openhuman::agent::stop_hooks::current_stop_hooks) +//! while the `CURRENT_STOP_HOOKS` task-local is in scope and handed to +//! [`StopHookMiddleware::new`]; the middleware is only registered when the list +//! is non-empty. + +use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; +use std::sync::{Arc, Mutex}; + +use async_trait::async_trait; +use tinyagents::harness::context::RunContext; +use tinyagents::harness::middleware::Middleware; +use tinyagents::harness::model::ModelResponse; +use tinyagents::harness::steering::{SteeringCommand, SteeringHandle}; + +use crate::openhuman::agent::cost::TurnCost; +use crate::openhuman::agent::stop_hooks::{StopDecision, StopHook, TurnState}; +use crate::openhuman::inference::provider::UsageInfo; + +/// Fires openhuman [`StopHook`]s after each model call and pauses the run when +/// any hook votes to stop. +pub struct StopHookMiddleware { + /// Steering handle the run was built with; `Pause` is sent here on stop. + handle: SteeringHandle, + /// Model name reported to hooks (and used for cost estimation). + model: String, + /// Configured model-call ceiling for this turn (the hooks' `max_iterations`). + max_iterations: u32, + /// 0-based count of completed model calls; incremented in `after_model`. + iteration: AtomicU32, + /// Per-turn usage tally the hooks read (`TurnState::cost`). + cost: Mutex, + /// Installed hooks, snapshotted from the task-local at construction. + hooks: Vec>, + /// Latches once a hook has voted to stop, so we send `Pause` exactly once. + stopped: AtomicBool, +} + +impl StopHookMiddleware { + /// Build a middleware firing `hooks`, pausing `handle` on the first stop. + pub fn new( + handle: SteeringHandle, + model: impl Into, + max_iterations: usize, + hooks: Vec>, + ) -> Self { + Self { + handle, + model: model.into(), + max_iterations: max_iterations.min(u32::MAX as usize) as u32, + iteration: AtomicU32::new(0), + cost: Mutex::new(TurnCost::new()), + hooks, + stopped: AtomicBool::new(false), + } + } +} + +#[async_trait] +impl Middleware for StopHookMiddleware +where + State: Send + Sync, + Ctx: Send + Sync, +{ + fn name(&self) -> &str { + "openhuman.stop_hooks" + } + + async fn after_model( + &self, + _ctx: &mut RunContext, + _state: &State, + response: &mut ModelResponse, + ) -> tinyagents::Result<()> { + // Already paused: nothing more to do (Pause is latched once). + if self.stopped.load(Ordering::SeqCst) { + return Ok(()); + } + + // Fold this call's usage into the running turn cost (the same tally the + // budget/goal hooks read). Clone it back out so we never hold the mutex + // guard across the async hook checks below. + let iteration = self.iteration.fetch_add(1, Ordering::SeqCst) + 1; + let cost_snapshot = { + let mut cost = self.cost.lock().expect("stop-hook cost mutex poisoned"); + if let Some(usage) = &response.usage { + cost.add_call( + &self.model, + &UsageInfo { + input_tokens: usage.input_tokens, + output_tokens: usage.output_tokens, + context_window: 0, + cached_input_tokens: usage.cache_read_tokens, + charged_amount_usd: 0.0, + }, + ); + } + cost.clone() + }; + + let turn_state = TurnState { + iteration, + max_iterations: self.max_iterations, + cost: &cost_snapshot, + model: &self.model, + }; + + for hook in &self.hooks { + if let StopDecision::Stop { reason } = hook.check(&turn_state).await { + // Latch first so a concurrent (streaming) after_model can't + // double-pause. + if self.stopped.swap(true, Ordering::SeqCst) { + return Ok(()); + } + tracing::warn!( + target: "stop_hooks", + hook = hook.name(), + iteration, + model = %self.model, + "[stop_hooks] hook voted to stop the turn — pausing run: {reason}" + ); + // Graceful stop: the loop drains steering at the top of the next + // iteration and `Pause` short-circuits it before the next model + // call. The partial transcript is returned to the caller. + self.handle.send(SteeringCommand::Pause); + return Ok(()); + } + } + + Ok(()) + } +} diff --git a/src/openhuman/tinyagents/summarize.rs b/src/openhuman/tinyagents/summarize.rs new file mode 100644 index 000000000..f0530fbe7 --- /dev/null +++ b/src/openhuman/tinyagents/summarize.rs @@ -0,0 +1,241 @@ +//! LLM-backed conversation summarization for the tinyagents harness path +//! (issue #4249). +//! +//! The harness historically only **front-trimmed** a long transcript +//! ([`tinyagents::harness::middleware::MessageTrimMiddleware`] with +//! [`TrimStrategy::MaxTokens`][tinyagents::harness::summarization::TrimStrategy]), +//! dropping the oldest turns wholesale once the thread neared the model's +//! context window. That is lossy: the dropped turns vanish. +//! +//! This module supplies the missing **summarization step** the per-folder graphs +//! install: an LLM-backed [`Summarizer`] that condenses the older slice of the +//! transcript into a single system message, driven by the crate's +//! [`ContextCompressionMiddleware`][tinyagents::harness::middleware::ContextCompressionMiddleware] +//! and a context-window-aware [`SummarizationPolicy`]. The policy only fires once +//! the running token estimate crosses [`SUMMARIZE_THRESHOLD_FRACTION`] of the +//! **current model's** context window — so the trigger is keyed to "whatever +//! model we are using", exactly mirroring the existing +//! [`ContextGuard`][crate::openhuman::context] compaction threshold (0.90). +//! +//! Layering: a graph installs the compression middleware **before** the +//! deterministic trim, so summarization is preferred and trimming remains only a +//! last-resort hard cap when even the summary + recent window overflow. + +use std::sync::Arc; + +use async_trait::async_trait; + +use tinyagents::error::{Result as TaResult, TinyAgentsError}; +use tinyagents::harness::message::Message as TaMessage; +use tinyagents::harness::summarization::{ + estimate_tokens, CompressionProvenance, SummarizationPolicy, Summarizer, SummaryRecord, +}; + +use crate::openhuman::inference::provider::Provider; + +/// Fraction of the model's context window at which summarization fires. +/// +/// Mirrors `ContextGuard::COMPACTION_TRIGGER_THRESHOLD` (0.90) so the tinyagents +/// path compacts at the same point the legacy `ContextManager` did. +pub const SUMMARIZE_THRESHOLD_FRACTION: f64 = 0.90; + +/// Number of most-recent non-system messages kept verbatim after a compaction. +/// The older head is folded into the summary; this tail stays untouched so the +/// model retains the live working context. +pub const SUMMARIZE_KEEP_LAST: usize = 8; + +/// An LLM-backed [`Summarizer`] that condenses a slice of harness [`TaMessage`]s +/// into a single system summary via an openhuman [`Provider`] chat call. +/// +/// Wraps the **same** provider + model the turn is already running on, so the +/// summary is produced by the active model (a cheaper summarizer model can be +/// threaded later if compaction on the main model proves expensive — the legacy +/// `ContextConfig::summarizer_model` hook). +pub struct ProviderModelSummarizer { + provider: Arc, + model: String, + temperature: f64, +} + +impl ProviderModelSummarizer { + /// Build a summarizer over `provider`/`model` at `temperature`. + pub fn new(provider: Arc, model: impl Into, temperature: f64) -> Self { + Self { + provider, + model: model.into(), + temperature, + } + } +} + +/// Role label for a harness message, used to render the plain-text transcript +/// the summarizer reads. +fn role_label(msg: &TaMessage) -> &'static str { + match msg { + TaMessage::System(_) => "system", + TaMessage::User(_) => "user", + TaMessage::Assistant(_) => "assistant", + TaMessage::Tool(_) => "tool", + } +} + +#[async_trait] +impl Summarizer for ProviderModelSummarizer { + async fn summarize(&self, messages: &[TaMessage]) -> TaResult { + if messages.is_empty() { + return Err(TinyAgentsError::Validation( + "cannot summarize an empty message list".into(), + )); + } + + let original_token_estimate: u64 = + messages.iter().map(|m| estimate_tokens(&m.text())).sum(); + // `Message` carries no stable id, so assign synthetic positional ids + // (matching the crate's `ConcatSummarizer` provenance convention). + let source_ids: Vec = (0..messages.len()).map(|i| format!("msg-{i}")).collect(); + + let transcript = messages + .iter() + .map(|m| format!("{}: {}", role_label(m), m.text())) + .collect::>() + .join("\n"); + + tracing::info!( + model = %self.model, + head_messages = messages.len(), + approx_input_tokens = original_token_estimate, + "[tinyagents::summarize] dispatching context-window summary" + ); + + let summary = self + .provider + .chat_with_system( + Some(SUMMARIZER_SYSTEM_PROMPT), + &transcript, + &self.model, + self.temperature, + ) + .await + .map_err(|e| { + tracing::warn!(error = %e, "[tinyagents::summarize] summarizer provider call failed"); + TinyAgentsError::Model(format!("summarizer provider call failed: {e}")) + })?; + + let summary = summary.trim(); + if summary.is_empty() { + return Err(TinyAgentsError::Model( + "summarizer returned empty response".into(), + )); + } + + let body = format!("=== Conversation Summary (compacted) ===\n{summary}"); + let summary_token_estimate = estimate_tokens(&body); + + tracing::info!( + model = %self.model, + summary_tokens = summary_token_estimate, + freed_tokens = original_token_estimate.saturating_sub(summary_token_estimate), + "[tinyagents::summarize] context-window summary complete" + ); + + Ok(SummaryRecord { + summary: TaMessage::system(body), + provenance: CompressionProvenance { + source_ids, + original_token_estimate, + summary_token_estimate, + reason: format!( + "ProviderModelSummarizer via {} (LLM compaction at {:.0}% of context window)", + self.model, + SUMMARIZE_THRESHOLD_FRACTION * 100.0 + ), + }, + }) + } +} + +/// Build the context-window-aware [`SummarizationPolicy`] for a model whose +/// input window is `context_window` tokens. +/// +/// The policy triggers compaction once the estimated transcript tokens reach +/// `context_window * `[`SUMMARIZE_THRESHOLD_FRACTION`] and keeps the most recent +/// [`SUMMARIZE_KEEP_LAST`] non-system messages (plus all system messages) +/// verbatim. Pair it with [`ProviderModelSummarizer`] via +/// [`ContextCompressionMiddleware::with_summarizer`][tinyagents::harness::middleware::ContextCompressionMiddleware::with_summarizer]. +pub fn summarization_policy(context_window: u64) -> SummarizationPolicy { + let mut policy = SummarizationPolicy::default() + .with_context_window(context_window) + .with_threshold_fraction(SUMMARIZE_THRESHOLD_FRACTION); + policy.keep_last = SUMMARIZE_KEEP_LAST; + policy +} + +/// System prompt for the context-window summarizer. Relocated here from the +/// former `context::summarizer` (issue #4249) — the tinyagents summarization +/// step is now its only consumer. +const SUMMARIZER_SYSTEM_PROMPT: &str = "You are a summarization agent creating a context \ +checkpoint for an AI assistant whose conversation has grown too long to fit its context window. \ +You are given the earlier portion of a chronological conversation (user, assistant, and tool \ +messages). Compress it into a dense, structured handoff note that the assistant will read as \ +BACKGROUND REFERENCE — not as new instructions.\n\ +\n\ +Rules:\n\ +- Write ONLY the structured summary below. No greeting, no preamble, no closing remarks.\n\ +- This is reference material describing turns that ALREADY happened. Do NOT answer any question \ +or perform any task mentioned in it. The assistant acts only on the live messages that appear \ +AFTER this summary; if a later message contradicts or changes topic, the later message wins.\n\ +- Redact secrets: replace any API keys, tokens, passwords, or credentials with [REDACTED] (note \ +that a credential was present).\n\ +- Be specific and information-dense: prefer concrete facts (paths, names, values, decisions) over \ +narration. Drop greetings, small talk, and redundant acknowledgements.\n\ +\n\ +Produce exactly these sections (write \"None\" when a section is empty):\n\ +\n\ +## Goal\n\ +What the user is ultimately trying to accomplish.\n\ +\n\ +## Completed Actions\n\ +Numbered list of what has already been done, with key results/outputs.\n\ +\n\ +## Active State\n\ +The current state of the work right now: files touched, systems configured, what is true.\n\ +\n\ +## Key Decisions\n\ +Decisions made and the reasoning, so they are not relitigated.\n\ +\n\ +## Resolved Questions\n\ +Questions already answered — include the answer so it is not repeated.\n\ +\n\ +## Pending / Open (reference only)\n\ +Requests or work outstanding in the compacted turns. These are STALE — do NOT act on them unless \ +the latest live message explicitly asks.\n\ +\n\ +## Relevant Files\n\ +Files read, created, or modified, with a one-line note on each.\n\ +\n\ +## Critical Context\n\ +Anything else essential to continue correctly (constraints, environment facts, gotchas)."; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn policy_is_context_window_aware_at_the_configured_threshold() { + let policy = summarization_policy(200_000); + assert_eq!(policy.context_window, Some(200_000)); + assert_eq!(policy.threshold_fraction, SUMMARIZE_THRESHOLD_FRACTION); + assert_eq!(policy.keep_last, SUMMARIZE_KEEP_LAST); + } + + #[test] + fn threshold_fraction_leaves_headroom_below_the_window() { + // The policy must trigger *before* the window is full, so the summary + // call itself has room — 90% by default. + assert!(SUMMARIZE_THRESHOLD_FRACTION > 0.0 && SUMMARIZE_THRESHOLD_FRACTION < 1.0); + let policy = summarization_policy(100_000); + // 90% of 100k = 90k tokens is the effective trigger point. + let effective = (policy.context_window.unwrap() as f64 * policy.threshold_fraction) as u64; + assert_eq!(effective, 90_000); + } +} diff --git a/src/openhuman/tinyagents/tests.rs b/src/openhuman/tinyagents/tests.rs new file mode 100644 index 000000000..0a59a4a69 --- /dev/null +++ b/src/openhuman/tinyagents/tests.rs @@ -0,0 +1,405 @@ +//! End-to-end tests for the `tinyagents` harness route: a real openhuman +//! [`Provider`] and [`Tool`] driven through [`run_turn_via_tinyagents`]. + +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::Arc; + +use async_trait::async_trait; + +use super::*; +use crate::openhuman::inference::provider::{ChatRequest, ChatResponse, Provider, ToolCall}; +use crate::openhuman::tools::{Tool, ToolResult}; + +/// A real openhuman tool the harness will execute. +struct EchoTool; + +#[async_trait] +impl Tool for EchoTool { + fn name(&self) -> &str { + "echo" + } + fn description(&self) -> &str { + "echoes its msg argument" + } + fn parameters_schema(&self) -> serde_json::Value { + serde_json::json!({ + "type": "object", + "properties": { "msg": { "type": "string" } }, + "required": ["msg"] + }) + } + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + let m = args.get("msg").and_then(|v| v.as_str()).unwrap_or(""); + Ok(ToolResult::success(format!("echoed:{m}"))) + } +} + +/// Mock provider: first call requests the echo tool, second call answers. +struct EchoThenDone { + calls: AtomicUsize, +} + +#[async_trait] +impl Provider for EchoThenDone { + async fn chat_with_system( + &self, + _s: Option<&str>, + _m: &str, + _model: &str, + _t: f64, + ) -> anyhow::Result { + Ok(String::new()) + } + async fn chat( + &self, + _r: ChatRequest<'_>, + _model: &str, + _t: f64, + ) -> anyhow::Result { + let n = self.calls.fetch_add(1, Ordering::SeqCst); + if n == 0 { + Ok(ChatResponse { + tool_calls: vec![ToolCall { + id: "call-1".to_string(), + name: "echo".to_string(), + arguments: r#"{"msg":"hi"}"#.to_string(), + extra_content: None, + }], + ..Default::default() + }) + } else { + Ok(ChatResponse { + text: Some("all done".to_string()), + ..Default::default() + }) + } + } + fn supports_native_tools(&self) -> bool { + true + } +} + +#[tokio::test] +async fn turn_runs_through_the_tinyagents_harness_with_real_tools() { + let provider = Arc::new(EchoThenDone { + calls: AtomicUsize::new(0), + }); + let history = vec![ChatMessage::user("please echo hi")]; + let tools: Vec> = vec![Arc::new(EchoTool)]; + + let outcome = run_turn_via_tinyagents(provider, "mock-model", 0.0, history, tools, 10) + .await + .expect("tinyagents harness turn runs"); + + assert_eq!(outcome.text, "all done"); + assert!(outcome.model_calls >= 2, "expected >=2 model calls"); + assert!(outcome.tool_calls >= 1, "expected the echo tool to run"); + assert!( + outcome + .history + .iter() + .any(|m| m.content.contains("echoed:hi")), + "tool result should be threaded into the transcript: {:?}", + outcome.history + ); +} + +/// A provider that streams visible text in chunks through the request's stream +/// sender, then returns the aggregated reply — exercising `ProviderModel::stream`. +struct StreamingProvider; + +#[async_trait] +impl Provider for StreamingProvider { + async fn chat_with_system( + &self, + _s: Option<&str>, + _m: &str, + _model: &str, + _t: f64, + ) -> anyhow::Result { + Ok(String::new()) + } + async fn chat( + &self, + r: ChatRequest<'_>, + _model: &str, + _t: f64, + ) -> anyhow::Result { + use crate::openhuman::inference::provider::{ProviderDelta, UsageInfo}; + if let Some(tx) = r.stream { + for chunk in ["Hel", "lo ", "world"] { + let _ = tx + .send(ProviderDelta::TextDelta { + delta: chunk.to_string(), + }) + .await; + } + } + Ok(ChatResponse { + text: Some("Hello world".to_string()), + usage: Some(UsageInfo { + input_tokens: 12, + output_tokens: 4, + ..Default::default() + }), + ..Default::default() + }) + } + fn supports_native_tools(&self) -> bool { + true + } +} + +#[tokio::test] +async fn streaming_path_forwards_text_deltas_and_cost() { + use crate::openhuman::agent::progress::AgentProgress; + + let (tx, mut rx) = tokio::sync::mpsc::channel::(64); + let registry: Arc>> = Arc::new(vec![]); + let history = vec![ChatMessage::user("hi")]; + + let outcome = run_turn_via_tinyagents_shared( + Arc::new(StreamingProvider), + "mock-model", + 0.0, + history, + vec![registry], + std::collections::HashSet::new(), + 4, + Some(tx), + None, + None, + None, + &[], + false, + None, + TurnContextMiddleware::defaults(), + ) + .await + .expect("streaming turn runs"); + + assert_eq!(outcome.text, "Hello world"); + assert_eq!((outcome.input_tokens, outcome.output_tokens), (12, 4)); + + // Collect the mirrored progress: incremental text deltas + a cost update. + let mut text = String::new(); + let mut saw_cost = false; + while let Ok(p) = rx.try_recv() { + match p { + AgentProgress::TextDelta { delta, .. } => text.push_str(&delta), + AgentProgress::TurnCostUpdated { input_tokens, .. } => { + assert_eq!(input_tokens, 12); + saw_cost = true; + } + _ => {} + } + } + assert!( + text.contains("Hello world"), + "incremental text deltas should reassemble the reply, got {text:?}" + ); + assert!(saw_cost, "a TurnCostUpdated should be emitted"); +} + +/// A provider that records the messages of every request it receives. +struct CapturingProvider { + captured: std::sync::Mutex>>, +} + +#[async_trait] +impl Provider for CapturingProvider { + async fn chat_with_system( + &self, + _s: Option<&str>, + _m: &str, + _model: &str, + _t: f64, + ) -> anyhow::Result { + Ok(String::new()) + } + async fn chat( + &self, + r: ChatRequest<'_>, + _model: &str, + _t: f64, + ) -> anyhow::Result { + self.captured.lock().unwrap().push(r.messages.to_vec()); + Ok(ChatResponse { + text: Some("acknowledged".to_string()), + ..Default::default() + }) + } + fn supports_native_tools(&self) -> bool { + true + } +} + +#[tokio::test] +async fn pre_queued_steer_message_is_injected_into_the_request() { + use crate::openhuman::agent::harness::run_queue::{QueueMode, QueuedMessage, RunQueue}; + + let provider = Arc::new(CapturingProvider { + captured: std::sync::Mutex::new(Vec::new()), + }); + let run_queue = RunQueue::new(); + run_queue + .push(QueuedMessage { + text: "switch focus to memory safety".into(), + mode: QueueMode::Steer, + client_id: "steer".into(), + thread_id: "t1".into(), + queued_at_ms: 0, + model_override: None, + temperature: None, + profile_id: None, + locale: None, + }) + .await; + + let registry: Arc>> = Arc::new(vec![]); + let outcome = run_turn_via_tinyagents_shared( + provider.clone(), + "mock-model", + 0.0, + vec![ChatMessage::user("investigate the bug")], + vec![registry], + std::collections::HashSet::new(), + 4, + None, + None, + None, + Some(run_queue), + &[], + false, + None, + TurnContextMiddleware::defaults(), + ) + .await + .expect("steered turn runs"); + + assert_eq!(outcome.text, "acknowledged"); + let captured = provider.captured.lock().unwrap(); + let steered = captured + .iter() + .flatten() + .any(|m| m.role == "user" && m.content.contains("switch focus to memory safety")); + assert!( + steered, + "the queued steer should be injected as a user turn, got: {:?}", + captured + .iter() + .flatten() + .map(|m| (&m.role, &m.content)) + .collect::>() + ); +} + +/// A provider that pops distinct scripted texts from a shared FIFO, recording +/// the order of consumption — models the global mock the parallel children share. +struct FifoProvider { + responses: std::sync::Mutex>, + calls: AtomicUsize, +} + +#[async_trait] +impl Provider for FifoProvider { + async fn chat_with_system( + &self, + _s: Option<&str>, + _m: &str, + _model: &str, + _t: f64, + ) -> anyhow::Result { + Ok(String::new()) + } + async fn chat( + &self, + _r: ChatRequest<'_>, + _model: &str, + _t: f64, + ) -> anyhow::Result { + self.calls.fetch_add(1, Ordering::SeqCst); + // Yield once so two concurrent turns on the same task actually interleave. + tokio::task::yield_now().await; + let text = self + .responses + .lock() + .unwrap() + .pop_front() + .unwrap_or_default(); + Ok(ChatResponse { + text: Some(text), + ..Default::default() + }) + } + fn supports_native_tools(&self) -> bool { + true + } +} + +/// Two sub-agent-style turns (`pause_at_cap = true`) running concurrently on the +/// *same task* (as `spawn_parallel_agents` does via `join_all`) must each get a +/// distinct FIFO response and not deadlock — the `parallel_subagent_fanout` +/// regression in miniature. +#[tokio::test] +async fn concurrent_shared_turns_each_get_a_distinct_result() { + let provider = Arc::new(FifoProvider { + responses: std::sync::Mutex::new( + ["AAA_CANARY".to_string(), "BBB_CANARY".to_string()].into(), + ), + calls: AtomicUsize::new(0), + }); + let registry: Arc>> = Arc::new(vec![]); + + let one = run_turn_via_tinyagents_shared( + provider.clone(), + "mock-model", + 0.0, + vec![ChatMessage::user("task one")], + vec![registry.clone()], + std::collections::HashSet::new(), + 4, + None, + None, + None, + None, + &[], + true, + None, + TurnContextMiddleware::defaults(), + ); + let two = run_turn_via_tinyagents_shared( + provider.clone(), + "mock-model", + 0.0, + vec![ChatMessage::user("task two")], + vec![registry], + std::collections::HashSet::new(), + 4, + None, + None, + None, + None, + &[], + true, + None, + TurnContextMiddleware::defaults(), + ); + + let (a, b) = tokio::join!(one, two); + let a = a.expect("turn one runs"); + let b = b.expect("turn two runs"); + + assert_eq!( + provider.calls.load(Ordering::SeqCst), + 2, + "exactly one model call per turn" + ); + let mut got = [a.text.as_str(), b.text.as_str()]; + got.sort_unstable(); + assert_eq!( + got, + ["AAA_CANARY", "BBB_CANARY"], + "each concurrent turn must receive a distinct FIFO response; got {got:?}" + ); +} diff --git a/src/openhuman/tinyagents/tools.rs b/src/openhuman/tinyagents/tools.rs new file mode 100644 index 000000000..131a3282b --- /dev/null +++ b/src/openhuman/tinyagents/tools.rs @@ -0,0 +1,431 @@ +//! `tinyagents` [`Tool`] adapter over an openhuman [`Tool`] (issue #4249). +//! +//! Wraps `Arc` so the harness agent-loop can invoke +//! the exact same tools the legacy loop runs. The harness calls `call` with a +//! validated [`TaToolCall`] (parsed JSON arguments + correlation id); we execute +//! the underlying tool and render the [`ToolResult`] the way the LLM should see +//! it (rendered via `output_for_llm`, matching the legacy tool loop). + +use std::sync::{Arc, Mutex}; + +use async_trait::async_trait; +use tinyagents::harness::steering::{SteeringCommand, SteeringHandle}; +use tinyagents::harness::tool::{ + Tool, ToolCall as TaToolCall, ToolResult as TaToolResult, ToolSchema, +}; + +/// Internal sentinel tool name. tinyagents fails the whole run on a call to an +/// unregistered tool ([`TinyAgentsError::ToolNotFound`]), but the legacy loop +/// returned an "Unknown tool" result and let the model recover. The model +/// adapter rewrites any call to an unadvertised tool onto this sentinel (the +/// original name carried in `requested_tool`), so the harness executes it, +/// produces the recovery result, and the loop continues — restoring the +/// graceful-unknown-tool behavior. The leading underscores keep it out of any +/// real tool namespace, and it is never advertised to the model. +pub const UNKNOWN_TOOL_SENTINEL: &str = "__openhuman_unknown_tool__"; + +/// The sentinel tool: reports the model's requested-but-unavailable tool back as +/// a recoverable result instead of aborting the run. See [`UNKNOWN_TOOL_SENTINEL`]. +/// +/// `subagent` selects the wording so it matches the legacy engine: a sub-agent +/// calling a tool outside its list gets the "not available to this sub-agent" +/// message (the `SubagentToolSource` wording), while a top-level agent gets the +/// "Unknown tool" message (`engine::tools`). Tests and the model key off these. +pub struct UnknownToolAdapter { + subagent: bool, +} + +impl UnknownToolAdapter { + pub fn new(subagent: bool) -> Self { + Self { subagent } + } +} + +#[async_trait] +impl Tool<()> for UnknownToolAdapter { + fn name(&self) -> &str { + UNKNOWN_TOOL_SENTINEL + } + + fn description(&self) -> &str { + "internal: reports an unavailable tool call" + } + + fn schema(&self) -> ToolSchema { + ToolSchema::new( + UNKNOWN_TOOL_SENTINEL, + "internal", + serde_json::json!({"type": "object"}), + ) + } + + async fn call(&self, _state: &(), call: TaToolCall) -> tinyagents::Result { + let requested = call + .arguments + .get("requested_tool") + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + let content = if self.subagent { + format!( + "Error: tool '{requested}' is not available to this sub-agent. \ + Use one of your listed tools, or answer directly." + ) + } else { + format!( + "Unknown tool: '{requested}'. It is not available; do not call it again. \ + Use one of the advertised tools, or answer directly." + ) + }; + Ok(TaToolResult { + call_id: call.id, + name: call.name, + content, + raw: None, + error: None, + elapsed_ms: 0, + }) + } +} + +/// A captured early-exit: a sub-agent invoked an early-exit tool (e.g. +/// `ask_user_clarification`), so the loop should pause and surface `question` +/// to the user. Mirrors the legacy `run_turn_engine` `early_exit_tool` seam. +#[derive(Debug, Clone)] +pub struct EarlyExit { + pub tool: String, + pub question: String, +} + +/// Shared early-exit hook handed to the adapters for the early-exit tool names. +/// On a successful call to one of those tools it records the [`EarlyExit`] and +/// sends a [`SteeringCommand::Pause`] so the harness loop short-circuits at the +/// next checkpoint (before the next model call) — the tinyagents analogue of the +/// legacy loop's "break on early-exit tool" behavior. +#[derive(Clone)] +pub struct EarlyExitHook { + handle: SteeringHandle, + slot: Arc>>, +} + +impl EarlyExitHook { + /// Build a hook that pauses `handle` and records into a fresh slot. + pub fn new(handle: SteeringHandle) -> Self { + Self { + handle, + slot: Arc::new(Mutex::new(None)), + } + } + + /// The captured early-exit, if one fired during the run. + pub fn take(&self) -> Option { + self.slot.lock().unwrap().take() + } + + /// Record an early-exit and request a cooperative pause. Only the first + /// early-exit in a run is kept (matching the legacy "halt on first"). + fn trigger(&self, tool: &str, question: String) { + { + let mut slot = self.slot.lock().unwrap(); + if slot.is_none() { + *slot = Some(EarlyExit { + tool: tool.to_string(), + question, + }); + } + } + tracing::info!(tool, "[tinyagents] early-exit tool — requesting pause"); + self.handle.send(SteeringCommand::Pause); + } +} + +/// A harness tool backed by an openhuman [`Tool`]. +pub struct ToolAdapter { + inner: Arc, +} + +impl ToolAdapter { + /// Wrap a resolved openhuman tool. + pub fn new(inner: Arc) -> Self { + Self { inner } + } +} + +#[async_trait] +impl Tool<()> for ToolAdapter { + fn name(&self) -> &str { + self.inner.name() + } + + fn description(&self) -> &str { + self.inner.description() + } + + fn schema(&self) -> ToolSchema { + super::convert::spec_to_schema(&self.inner.spec()) + } + + async fn call(&self, _state: &(), call: TaToolCall) -> tinyagents::Result { + Ok(execute_openhuman_tool(self.inner.as_ref(), call).await) + } +} + +/// Execute an openhuman [`Tool`](crate::openhuman::tools::Tool) for a harness +/// [`TaToolCall`] and render the [`TaToolResult`] the way the LLM should see it +/// (mirrors the live-path `HarnessToolExecutor`). +async fn execute_openhuman_tool( + tool: &dyn crate::openhuman::tools::Tool, + call: TaToolCall, +) -> TaToolResult { + tracing::debug!( + tool = %call.name, + call_id = %call.id, + "[tinyagents] executing openhuman tool via harness adapter" + ); + + // Approval (HITL) now runs in `ApprovalSecurityMiddleware` + // (`tinyagents/middleware.rs`, a `wrap_tool` middleware) so a denial + // short-circuits before this executor is reached. + // + // Execute through the session tool semantics the live path used + // (`agent_tool_exec`): `execute_with_options` (so markdown-capable tools + // render markdown) under the tool's resolved timeout deadline. Without the + // deadline an inherited/long-running tool call could hang the turn + // indefinitely. (Per-call `ToolPolicy`/permission gating needs the session + // policy context, which the per-tool adapter does not carry — the advertised + // allow-list + `UnknownToolRewriteMiddleware` already block unadvertised + // tools, and approval covers external effects.) + let options = crate::openhuman::tools::ToolCallOptions { + prefer_markdown: true, + }; + let (deadline, timeout_secs) = + crate::openhuman::tool_timeout::resolve_tool_deadline(tool.timeout_policy(&call.arguments)); + let exec = tool.execute_with_options(call.arguments.clone(), options); + let outcome = match deadline { + Some(d) => match tokio::time::timeout(d, exec).await { + Ok(r) => r, + Err(_) => { + tracing::warn!( + tool = %call.name, + timeout_secs, + "[tinyagents] tool timed out" + ); + return TaToolResult { + call_id: call.id, + name: call.name.clone(), + content: format!( + "Error: tool '{}' timed out after {timeout_secs}s", + call.name + ), + raw: None, + error: Some(format!("tool '{}' timed out", call.name)), + elapsed_ms: timeout_secs.saturating_mul(1000), + }; + } + }, + None => exec.await, + }; + match outcome { + Ok(result) => { + let content = result.output_for_llm(true); + let error = if result.is_error { + Some(content.clone()) + } else { + None + }; + TaToolResult { + call_id: call.id, + name: call.name, + content, + raw: None, + error, + elapsed_ms: 0, + } + } + Err(e) => { + tracing::warn!(tool = %call.name, error = %e, "[tinyagents] tool failed"); + TaToolResult { + call_id: call.id, + name: call.name.clone(), + content: format!("Error executing '{}': {e}", call.name), + raw: None, + error: Some(e.to_string()), + elapsed_ms: 0, + } + } + } +} + +/// A harness tool backed by the routes' shared, `Arc`-owned tool registry sets +/// (`Arc>>`). One adapter is registered per advertised tool +/// name; on call it locates the named tool across the shared sets and executes +/// it — the tinyagents analogue of the live path's `SharedToolExecutor`, which +/// lets a route reuse the same `Arc`-shared tools the legacy loop runs without +/// cloning them. +pub struct SharedToolAdapter { + sets: Vec>>>, + name: String, + description: String, + schema: ToolSchema, + /// When set, a successful call records an [`EarlyExit`] and pauses the loop. + early_exit: Option, +} + +impl SharedToolAdapter { + /// Build an adapter for the tool named `name`, locating it across `sets` to + /// capture its advertised spec. Returns `None` when no set contains it. + pub fn for_name( + sets: Vec>>>, + name: &str, + ) -> Option { + let spec = sets + .iter() + .flat_map(|set| set.iter()) + .find(|t| t.name() == name) + .map(|t| t.spec())?; + Some(Self { + sets, + name: spec.name.clone(), + description: spec.description.clone(), + schema: super::convert::spec_to_schema(&spec), + early_exit: None, + }) + } + + /// Treat this tool as an early-exit tool: a successful call records the + /// question and pauses the run via `hook`. + pub fn with_early_exit(mut self, hook: EarlyExitHook) -> Self { + self.early_exit = Some(hook); + self + } +} + +#[async_trait] +impl Tool<()> for SharedToolAdapter { + fn name(&self) -> &str { + &self.name + } + + fn description(&self) -> &str { + &self.description + } + + fn schema(&self) -> ToolSchema { + self.schema.clone() + } + + async fn call(&self, _state: &(), call: TaToolCall) -> tinyagents::Result { + let found = self + .sets + .iter() + .flat_map(|set| set.iter()) + .find(|t| t.name() == self.name); + match found { + Some(tool) => { + let result = execute_openhuman_tool(tool.as_ref(), call).await; + // Early-exit (e.g. `ask_user_clarification`): on a successful + // call, record the question and pause so the runner can + // checkpoint and surface the prompt — matching the legacy seam. + if let Some(hook) = &self.early_exit { + if result.error.is_none() { + hook.trigger(&self.name, result.content.clone()); + } + } + Ok(result) + } + None => { + tracing::warn!(tool = %self.name, "[tinyagents] shared tool not found"); + Ok(TaToolResult { + call_id: call.id, + name: call.name, + content: format!("Error: unknown tool '{}'", self.name), + raw: None, + error: Some("unknown tool".to_string()), + elapsed_ms: 0, + }) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::openhuman::tools::traits::ToolTimeout; + use crate::openhuman::tools::ToolResult as OhToolResult; + + /// A tool whose `execute_with_options` sleeps forever but declares a short + /// per-call timeout, so the adapter's deadline must fire. + struct HangingTool; + + #[async_trait] + impl crate::openhuman::tools::Tool for HangingTool { + fn name(&self) -> &str { + "hang" + } + fn description(&self) -> &str { + "hangs" + } + fn parameters_schema(&self) -> serde_json::Value { + serde_json::json!({ "type": "object" }) + } + async fn execute(&self, _args: serde_json::Value) -> anyhow::Result { + futures_util::future::pending::<()>().await; + Ok(OhToolResult::success("never")) + } + fn timeout_policy(&self, _args: &serde_json::Value) -> ToolTimeout { + ToolTimeout::Secs(1) + } + } + + /// A fast tool that echoes an argument, to prove the normal path still runs. + struct EchoTool; + + #[async_trait] + impl crate::openhuman::tools::Tool for EchoTool { + fn name(&self) -> &str { + "echo" + } + fn description(&self) -> &str { + "echoes" + } + fn parameters_schema(&self) -> serde_json::Value { + serde_json::json!({ "type": "object" }) + } + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + let m = args.get("msg").and_then(|v| v.as_str()).unwrap_or(""); + Ok(OhToolResult::success(format!("echoed:{m}"))) + } + } + + fn call(name: &str, args: serde_json::Value) -> TaToolCall { + TaToolCall { + id: "c1".into(), + name: name.into(), + arguments: args, + } + } + + #[tokio::test] + async fn tool_execution_respects_the_per_call_timeout() { + let result = + execute_openhuman_tool(&HangingTool, call("hang", serde_json::json!({}))).await; + assert!( + result + .error + .as_deref() + .is_some_and(|e| e.contains("timed out")), + "a hanging tool must surface a timeout error, got {:?}", + result.error + ); + assert!(result.content.contains("timed out")); + } + + #[tokio::test] + async fn fast_tool_runs_to_completion() { + let result = + execute_openhuman_tool(&EchoTool, call("echo", serde_json::json!({ "msg": "hi" }))) + .await; + assert!(result.error.is_none()); + assert!(result.content.contains("echoed:hi")); + } +} diff --git a/src/openhuman/tinyagents/topology.rs b/src/openhuman/tinyagents/topology.rs new file mode 100644 index 000000000..8a276d362 --- /dev/null +++ b/src/openhuman/tinyagents/topology.rs @@ -0,0 +1,106 @@ +//! Graph topology export for debug / inspection (issue #4249, Phase 4). +//! +//! Every custom OpenHuman graph exposes a `*_topology()` builder that constructs +//! its structure with no-op stub closures and returns a behaviour-free +//! [`GraphTopology`] (node names, edges, routing, and a structural validation +//! report — never closure bodies). [`all_graph_topologies`] collects them so a +//! UI / debug endpoint can render the orchestration graphs as JSON or Mermaid +//! and surface any structural defects. + +use tinyagents::graph::export::{self, GraphTopology}; + +/// A rendered topology for one graph. +pub struct GraphTopologyReport { + /// Stable graph label (e.g. `"agent_teams:member"`). + pub name: &'static str, + /// Mermaid `flowchart TD` rendering. + pub mermaid: String, + /// Pretty-printed JSON of the full topology. + pub json: String, + /// `true` when the structural validation found no errors. + pub ok: bool, + /// Structural defects (missing nodes, unreachable routes, …). + pub errors: Vec, + /// Non-fatal observations. + pub warnings: Vec, +} + +/// Render a [`GraphTopology`] into a [`GraphTopologyReport`]. +pub fn describe(name: &'static str, topology: &GraphTopology) -> GraphTopologyReport { + GraphTopologyReport { + name, + mermaid: export::to_mermaid(topology), + json: export::to_json(topology), + ok: topology.validation.ok, + errors: topology.validation.errors.clone(), + warnings: topology.validation.warnings.clone(), + } +} + +/// Collect structure-only topologies of every custom OpenHuman graph. +/// +/// Graphs that fail to build (should not happen for the fixed-structure graphs) +/// are silently skipped. Each entry carries a Mermaid + JSON rendering and the +/// structural validation report. +pub fn all_graph_topologies() -> Vec { + let mut out = Vec::new(); + + if let Ok(t) = crate::openhuman::agent_orchestration::agent_teams::member_graph_topology() { + out.push(describe("agent_teams:member", &t)); + } + + // Follow-ups (same `build_*` extract-and-reuse pattern as the member graph): + // the `delegation` graph (injected `run_stage` — clean to add) and the + // `workflow_runs` scheduler graph (its node closures capture engine locals, + // so it needs a small refactor first). The generic item-count-driven + // fan-outs (`model_council`, `run_parallel_fanout` — dispatch → N workers → + // collect) are the fan-out pattern rather than a fixed named topology. + + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn all_topologies_includes_the_member_graph() { + let reports = all_graph_topologies(); + let member = reports + .iter() + .find(|r| r.name == "agent_teams:member") + .expect("the agent_teams member graph should be exported"); + + // The member graph is a fixed, well-formed structure. + assert!( + member.ok, + "member graph should validate structurally: {:?}", + member.errors + ); + assert!(member.errors.is_empty()); + } + + #[test] + fn member_report_renders_mermaid_and_valid_json() { + let t = crate::openhuman::agent_orchestration::agent_teams::member_graph_topology() + .expect("member topology builds"); + let report = describe("agent_teams:member", &t); + + // Mermaid is a flowchart with at least the entry node rendered. + assert!( + report.mermaid.contains("flowchart"), + "mermaid should be a flowchart: {}", + report.mermaid + ); + assert!(!t.nodes.is_empty(), "the graph should declare nodes"); + + // JSON round-trips to a value carrying the same node set. + let parsed: serde_json::Value = + serde_json::from_str(&report.json).expect("topology JSON parses"); + assert!( + parsed.get("nodes").is_some(), + "serialized topology should carry its nodes: {}", + report.json + ); + } +} diff --git a/src/openhuman/tinyplace/agent/graph.rs b/src/openhuman/tinyplace/agent/graph.rs new file mode 100644 index 000000000..d63609499 --- /dev/null +++ b/src/openhuman/tinyplace/agent/graph.rs @@ -0,0 +1,13 @@ +//! Turn graph for the `tinyplace_agent` built-in agent. +//! +//! Uses the shared default sub-agent turn graph (`run_subagent_via_graph`) — see +//! [`crate::openhuman::agent::harness::agent_graph`]. Replace the body with +//! `AgentGraph::custom(run)` to give this agent a bespoke tinyagents graph. + +use crate::openhuman::agent::harness::agent_graph::AgentGraph; + +/// Select this agent's turn graph. This is a default agent — it uses the shared +/// default graph rather than defining its own. +pub fn graph() -> AgentGraph { + AgentGraph::Default +} diff --git a/src/openhuman/tinyplace/agent/mod.rs b/src/openhuman/tinyplace/agent/mod.rs index 8bf84783c..cedb79d0e 100644 --- a/src/openhuman/tinyplace/agent/mod.rs +++ b/src/openhuman/tinyplace/agent/mod.rs @@ -1 +1,2 @@ +pub mod graph; pub mod prompt; diff --git a/src/openhuman/tool_timeout/mod.rs b/src/openhuman/tool_timeout/mod.rs index 90e2b65b2..e90195bbe 100644 --- a/src/openhuman/tool_timeout/mod.rs +++ b/src/openhuman/tool_timeout/mod.rs @@ -152,6 +152,43 @@ pub fn explicit_call_timeout_duration(requested: Option, cap: u64) -> Optio explicit_call_timeout_secs(requested, cap).map(Duration::from_secs) } +/// Extra slack added on top of an explicit per-call budget before the hard +/// `tokio::time::timeout` fires, so a tool that finishes right at its requested +/// deadline isn't killed by scheduler jitter. The user-facing `timeout_secs` +/// reported on a timeout is the un-padded request. +const TOOL_TIMEOUT_GRACE_SECS: u64 = 5; + +/// Resolve a tool's [`ToolTimeout`] policy into the `(deadline, timeout_secs)` +/// pair the agent tool-execution loop enforces: +/// - `Inherit` → the global config-driven timeout (a finite deadline). +/// - `Secs(req)` → the clamped request, padded by [`TOOL_TIMEOUT_GRACE_SECS`] +/// for the actual deadline while `timeout_secs` reports the un-padded budget. +/// - `Unbounded` → `(None, 0)`: no deadline; the tool runs to completion. +/// +/// Moved out of the retired legacy `engine::tools` module during the tinyagents +/// migration (issue #4249); it lives here next to the timeout constants it uses. +pub fn resolve_tool_deadline( + policy: crate::openhuman::tools::traits::ToolTimeout, +) -> (Option, u64) { + use crate::openhuman::tools::traits::ToolTimeout; + match policy { + ToolTimeout::Inherit => { + let s = tool_execution_timeout_secs(); + (Some(Duration::from_secs(s)), s) + } + ToolTimeout::Secs(req) => { + let s = req.clamp(MIN_TIMEOUT_SECS, MAX_TIMEOUT_SECS); + ( + Some(Duration::from_secs( + s.saturating_add(TOOL_TIMEOUT_GRACE_SECS), + )), + s, + ) + } + ToolTimeout::Unbounded => (None, 0), + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/openhuman/tools/ops.rs b/src/openhuman/tools/ops.rs index affe676af..f54e4175b 100644 --- a/src/openhuman/tools/ops.rs +++ b/src/openhuman/tools/ops.rs @@ -183,6 +183,11 @@ pub fn all_tools_with_runtime( Box::new(ContinueSubagentTool::new()), Box::new(SpawnParallelAgentsTool::new()), Box::new(DelegateToPersonalityTool::new()), + // Multi-stage durable delegation (issue #4249, Phase 3): runs the chosen + // sub-agent through the tinyagents plan→execute→review→finalize graph, + // checkpointed to the session DB. Heavier than spawn_subagent; for + // sub-tasks that benefit from a self-review/revision loop. + Box::new(DelegateGraphTool::new()), // Coding-harness control flow (issue #1205): a process-global // todo registry the agent can rewrite end-to-end, plus the // `plan_exit` marker that hands a plan-mode pass off to a diff --git a/src/openhuman/tools/orchestrator_tools.rs b/src/openhuman/tools/orchestrator_tools.rs index 12e88d836..58b155d42 100644 --- a/src/openhuman/tools/orchestrator_tools.rs +++ b/src/openhuman/tools/orchestrator_tools.rs @@ -286,6 +286,7 @@ mod tests { delegate_name: delegate_name.map(String::from), agent_tier: crate::openhuman::agent::harness::definition::AgentTier::Worker, source: DefinitionSource::Builtin, + graph: Default::default(), } } diff --git a/src/openhuman/workflows/e2e_plumbing_tests.rs b/src/openhuman/workflows/e2e_plumbing_tests.rs index 4157f5e92..ee224ff6d 100644 --- a/src/openhuman/workflows/e2e_plumbing_tests.rs +++ b/src/openhuman/workflows/e2e_plumbing_tests.rs @@ -27,7 +27,7 @@ use std::time::Duration; use async_trait::async_trait; use parking_lot::Mutex; -use crate::openhuman::agent::harness::run_tool_call_loop; +use crate::openhuman::agent::harness::run_channel_turn_via_graph; use crate::openhuman::agent::tools::RunWorkflowTool; use crate::openhuman::config::{Config, MultimodalConfig, MultimodalFileConfig}; use crate::openhuman::inference::provider::traits::{ChatMessage, ProviderCapabilities}; @@ -171,15 +171,15 @@ async fn mock_llm_orchestrator_lists_and_runs_workflows_through_the_loop() { let config = Arc::new(config); // The two tools the orchestrator now carries for workflows. - let tools: Vec> = vec![ + let tools: Arc>> = Arc::new(vec![ Box::new(crate::openhuman::workflows::tools::WorkflowListTool::new( config.clone(), )), Box::new(RunWorkflowTool::new()), - ]; + ]); // Scripted: discover → attempt to run an unknown workflow → wrap up. - let provider = ScriptedProvider { + let provider: Arc = Arc::new(ScriptedProvider { responses: Mutex::new(vec![ Ok(tool_call("c1", "list_workflows", serde_json::json!({}))), Ok(tool_call( @@ -191,27 +191,21 @@ async fn mock_llm_orchestrator_lists_and_runs_workflows_through_the_loop() { )), Ok(final_text("done")), ]), - }; + }); let mut history = vec![ChatMessage::user("Triage my inbox using a workflow.")]; - let result = run_tool_call_loop( - &provider, + let result = run_channel_turn_via_graph( + provider, &mut history, - &tools, - "test-provider", + tools, + vec![], + None, "model", 0.0, - true, - "channel", - &MultimodalConfig::default(), - &MultimodalFileConfig::default(), 5, + MultimodalConfig::default(), + MultimodalFileConfig::default(), None, - None, - &[], - None, - None, - &DefaultToolPolicy, ) .await .expect("tool loop should run to completion"); diff --git a/src/openhuman/workflows/e2e_run_tests.rs b/src/openhuman/workflows/e2e_run_tests.rs index dabf196f5..08980459f 100644 --- a/src/openhuman/workflows/e2e_run_tests.rs +++ b/src/openhuman/workflows/e2e_run_tests.rs @@ -28,7 +28,7 @@ use std::time::Duration; use async_trait::async_trait; -use crate::openhuman::agent::harness::run_tool_call_loop; +use crate::openhuman::agent::harness::run_channel_turn_via_graph; use crate::openhuman::agent::task_board::{TaskBoardCard, TaskCardStatus}; use crate::openhuman::agent::task_dispatcher::{dispatch_card, DispatchOutcome}; use crate::openhuman::agent::tools::RunWorkflowTool; @@ -233,30 +233,24 @@ async fn orchestrator_runs_workflow_tool_and_gets_inner_result() { workflow_id: Some("triage-inbox".into()), })); - let provider = MockLlm { + let provider: Arc = Arc::new(MockLlm { workflow_id: Some("triage-inbox".into()), - }; - let tools: Vec> = vec![Box::new(RunWorkflowTool::new())]; + }); + let tools: Arc>> = Arc::new(vec![Box::new(RunWorkflowTool::new())]); let mut history = vec![ChatMessage::user("Triage my inbox.")]; - let result = run_tool_call_loop( - &provider, + let result = run_channel_turn_via_graph( + provider, &mut history, - &tools, - "test-provider", + tools, + vec![], + None, "model", 0.0, - true, - "channel", - &MultimodalConfig::default(), - &MultimodalFileConfig::default(), 5, + MultimodalConfig::default(), + MultimodalFileConfig::default(), None, - None, - &[], - None, - None, - &DefaultToolPolicy, ) .await .expect("orchestrator loop should complete"); diff --git a/tests/agent_harness_public.rs b/tests/agent_harness_public.rs index fe0d570cd..d23da54b5 100644 --- a/tests/agent_harness_public.rs +++ b/tests/agent_harness_public.rs @@ -1,7 +1,7 @@ use anyhow::Result; use async_trait::async_trait; use openhuman_core::openhuman::agent::harness::{ - check_interrupt, current_parent, with_parent_context, InterruptFence, ParentExecutionContext, + current_parent, with_parent_context, ParentExecutionContext, }; use openhuman_core::openhuman::agent::hooks::{ fire_hooks, sanitize_tool_output, PostTurnHook, ToolCallRecord, TurnContext, @@ -12,7 +12,6 @@ use openhuman_core::openhuman::inference::provider::{ }; use openhuman_core::openhuman::memory::{Memory, MemoryCategory, MemoryEntry}; use parking_lot::Mutex; -use std::sync::atomic::Ordering; use std::sync::Arc; use tokio::sync::Notify; @@ -177,31 +176,9 @@ impl PostTurnHook for RecordingHook { } } -#[test] -fn interrupt_fence_shares_and_resets_state() { - let fence = InterruptFence::default(); - assert!(!fence.is_interrupted()); - assert!(check_interrupt(&fence).is_ok()); - - let clone = fence.clone(); - let raw = fence.flag_handle(); - fence.trigger(); - assert!(clone.is_interrupted()); - assert!(raw.load(Ordering::Relaxed)); - assert!(check_interrupt(&fence).is_err()); - - raw.store(false, Ordering::Relaxed); - fence.reset(); - assert!(!fence.is_interrupted()); -} - -#[tokio::test] -async fn interrupt_signal_handler_is_installable() { - let fence = InterruptFence::new(); - fence.install_signal_handler(); - tokio::time::sleep(std::time::Duration::from_millis(10)).await; - assert!(!fence.is_interrupted()); -} +// The legacy `InterruptFence` / `check_interrupt` surface was removed in #4249 +// (user-driven cancellation is now the tinyagents steering/cancellation channel), +// so the public-API tests that exercised it are gone with it. #[tokio::test] async fn parent_context_is_visible_only_within_scope() { diff --git a/tests/inference_agent_raw_coverage_e2e.rs b/tests/inference_agent_raw_coverage_e2e.rs index b4acfb8d7..b0e659bae 100644 --- a/tests/inference_agent_raw_coverage_e2e.rs +++ b/tests/inference_agent_raw_coverage_e2e.rs @@ -42,8 +42,7 @@ use openhuman_core::openhuman::agent::harness::subagent_runner::{ SubagentRunOptions, SubagentRunOutcome, SubagentRunStatus, SubagentUsage, }; use openhuman_core::openhuman::agent::harness::{ - check_interrupt, current_sandbox_mode, with_current_sandbox_mode, InterruptFence, - InterruptedError, SandboxMode, + current_sandbox_mode, with_current_sandbox_mode, SandboxMode, }; use openhuman_core::openhuman::agent::harness::{ AgentDefinition, AgentDefinitionRegistry, DefinitionSource, ModelSpec, PromptSource, ToolScope, @@ -4245,17 +4244,8 @@ async fn agent_error_hooks_interrupt_and_stop_hooks_cover_public_paths() { AgentError::MaxIterationsExceeded { max: 3 } )); - let fence = InterruptFence::new(); - assert!(check_interrupt(&fence).is_ok()); - let shared = fence.flag_handle(); - shared.store(true, std::sync::atomic::Ordering::Relaxed); - assert!(fence.is_interrupted()); - assert!(matches!(check_interrupt(&fence), Err(InterruptedError))); - fence.reset(); - assert!(!fence.is_interrupted()); - let cloned = fence.clone(); - cloned.trigger(); - assert!(fence.is_interrupted()); + // The legacy InterruptFence / check_interrupt surface was removed in #4249 + // (cancellation is now the tinyagents steering/cancellation channel). assert_eq!(current_sandbox_mode(), None); with_current_sandbox_mode(SandboxMode::ReadOnly, async {