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)
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Settings
-
-
-
-
-
AI
-
preview
-
-
-
-
-
-
-
-
Cloud providers
-
- Add
-
-
-
-
-
-
-
-
-
-
-
-
- Primary
-
-
-
-
-
-
-
-
- Set primary
-
-
-
-
-
-
-
-
-
-
- Signed-in default · no configuration needed
-
-
-
- Endpoint
-
- Key
-
-
-
-
- Model
-
-
-
-
-
-
-
-
-
-
-
- Local provider
-
-
-
-
-
-
-
-
running
-
ollama · v0.3.14
-
-
- Stop
-
-
- Update
-
-
-
-
-
-
- Pull a model
-
-
-
-
-
-
-
-
-
Workload routing
-
-
- Cloud
-
-
- Local
-
-
- Mixed
-
-
-
-
-
-
-
Chat
-
-
-
-
-
-
- Primary
- Cloud
- Local
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Background
-
-
-
-
-
-
- Primary
- Cloud
- Local
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Primary resolves to
-
-
-
-
-
-
-
-
-
-
-
-
-
- unsaved change
-
-
-
-
- Discard
-
-
- Save
-
-
-
-
-
-
-
-
-
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
-
-
-
-
-
-
-
-
-
-
-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