feat(agent): welcome->orchestrator routing + per-agent tool scoping (#525, #526) (#544)

* feat(agent): add subagents + delegate_name fields to AgentDefinition (#525, #526)

Introduces the schema change needed to make agent definitions the single source
of truth for both direct tools and delegation targets:

- `subagents: Vec<SubagentEntry>` — declarative list of agents this agent can
  spawn, expanding at build time into synthesised delegate_* tools on the LLM's
  function-calling surface. Supports two TOML shapes via `#[serde(untagged)]`:
    * Bare string (`"researcher"`) → `SubagentEntry::AgentId`
    * Inline table (`{ skills = "*" }`) → `SubagentEntry::Skills(SkillsWildcard)`
  The `Skills` variant expands dynamically to one delegate_{toolkit} tool per
  connected Composio toolkit at runtime.

- `delegate_name: Option<String>` — optional override for the tool name this
  agent is exposed as when another agent lists it in `subagents`. Defaults to
  `delegate_{id}` when absent, lets the researcher agent be exposed as
  `research`, code_executor as `run_code`, etc.

Schema only — no runtime behavior change yet. Follow-up commits wire the field
into `collect_orchestrator_tools`, the dispatch path, and the debug dump.

TOML placement note: `subagents = [...]` must appear before the `[tools]` table
header in agent TOMLs. Once a table section opens, every subsequent top-level
key is consumed by that table, so placing `subagents` after `[tools]` parses it
as `tools.subagents` and fails deserializing ToolScope. The test doc-comment
records this constraint.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(agent): drive orchestrator delegation tools from TOML subagents (#525, #526)

Completes the half-built migration from a hardcoded ARCHETYPE_TOOLS table
+ orphan skill_delegation.rs to a TOML-driven delegation surface where
each agent declares its subagents and the tool list is synthesised from
the registry at agent-build time.

Rewrites `collect_orchestrator_tools` to take the orchestrator's
AgentDefinition, the global AgentDefinitionRegistry, and the slice of
connected Composio integrations, then iterates the definition's
`subagents` field:

  * `SubagentEntry::AgentId` → one ArchetypeDelegationTool. The tool's
    `name()` comes from the target agent's `delegate_name` override (or
    `delegate_{id}` fallback) and its `description()` is the target's
    `when_to_use`. Editing an agent's TOML when_to_use now immediately
    updates the tool schema the orchestrator LLM sees — zero drift, no
    hardcoded description strings to keep in sync.

  * `SubagentEntry::Skills { skills = "*" }` → one SkillDelegationTool
    per connected Composio toolkit. Each routes to the generic
    skills_agent with `skill_filter` pre-populated. Empty integrations
    list (CLI path, or user not yet connected) produces zero tools
    rather than phantom `delegate_*` entries for unconnected toolkits.

Also in this commit:

- Wire the orphan `skill_delegation.rs` into `impl/agent/mod.rs` via
  `mod skill_delegation;` + `pub use SkillDelegationTool;`. The file has
  existed since earlier work but was never declared in the module tree,
  so it compiled as dead code.

- Delete the legacy `MAIN_AGENT_TOOL_ALLOWLIST` constant and the
  `main_agent_tools` filter in `tools/ops.rs`. They were documented as
  "no longer the primary source of truth" since the from_config builder
  switched to `collect_orchestrator_tools`, and grep confirms no
  external callers remain. Clean deletion.

- Delete the hardcoded `ARCHETYPE_TOOLS` const in
  `tools/impl/agent/mod.rs`. The 4-entry table has been replaced by the
  orchestrator TOML's `subagents` list (which covers those 4 plus
  archivist plus the skills wildcard), and the re-export in
  `tools/mod.rs` is removed accordingly.

- Update `agents/orchestrator/agent.toml`: add the `subagents` field
  listing researcher / planner / code_executor / critic / archivist /
  { skills = "*" }. Keep `spawn_subagent` in `[tools] named` as an
  advanced fallback so power users can still spawn custom workspace-
  override agent ids that aren't in the declarative subagents list.

- Add `delegate_name = "..."` to the 5 archetype TOMLs so the
  orchestrator LLM sees natural tool names (`research`, `plan`,
  `run_code`, `review_code`, `archive_session`) rather than the
  `delegate_<agent_id>` fallback.

- Update `agent/harness/session/builder.rs` (line ~461) to call the new
  `collect_orchestrator_tools` signature. Looks up the orchestrator
  definition from the global registry; passes an empty integrations
  slice because the builder is synchronous and cannot await Composio's
  async fetch. The channel-dispatch path will populate integrations in
  a later commit — the CLI/REPL path ships without per-toolkit delegation
  tools, which is acceptable regression since CLI users still reach
  Composio via `composio_execute` and the retained `spawn_subagent`
  fallback.

Tests:

  * 5 new unit tests in `orchestrator_tools.rs` cover the baseline
    AgentId + Skills wildcard expansion, empty-integrations edge case,
    unknown-id graceful skip, non-delegating agent with empty subagents,
    and the slug sanitiser for tool-name-safe Composio toolkit names.
  * Runs clean alongside all existing agent-module tests (323 pass;
    one pre-existing Windows-path failure in `self_healing::tests::
    tool_maker_prompt_includes_command` is unrelated to this PR and
    fails identically on the upstream baseline).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(agent): plumb per-agent tool scoping through bus + tool loop (#525, #526)

Adds the parameter plumbing for agent-aware tool filtering without
changing any runtime behaviour. Every existing call site continues to
pass `None` / empty extras, so the LLM still sees the full unfiltered
registry — the actual routing logic that populates these fields lands
in commit 4b (dispatch.rs onboarding-flag → target_agent_id).

Why two parameters and not one filter:

  Tools in this codebase are `Box<dyn Tool>` — owned trait objects with
  no Clone impl, stored in a shared `Arc<Vec<Box<dyn Tool>>>`. We can't
  cheaply build a per-turn filtered subset of the global registry, and
  we can't mutate the Arc to remove entries. Two new parameters work
  around this without touching the global registry's lifetime model:

  * `visible_tool_names: Option<&HashSet<String>>` — whitelist filter
    applied at the iteration site inside `run_tool_call_loop`. When
    `Some(set)`, only tools whose `name()` is in the set contribute to
    the function-calling schema and are eligible for execution; every
    other tool in the combined registry is hidden from the model and
    rejected if the model emits a call for it. `None` preserves the
    legacy "everything visible" behaviour.

  * `extra_tools: &[Box<dyn Tool>]` — per-turn synthesised tools spliced
    alongside `tools_registry`. The dispatch path will use this to
    surface delegation tools (`research`, `delegate_gmail`, …) that are
    built fresh each turn from the active agent's `subagents` field and
    the current Composio integration list — tools that don't exist in
    the global startup-time registry because they depend on per-user
    runtime state. Empty slice for agents that don't delegate.

  Inside the loop, `tool_specs` is built from
  `tools_registry.iter().chain(extra_tools.iter()).filter(is_visible)`,
  and the tool-execution lookup uses the same chain + filter so the
  function-calling schema and the execution surface stay in sync.

Files touched in this commit:

  src/openhuman/agent/harness/tool_loop.rs
    - Add `visible_tool_names` and `extra_tools` parameters to
      `run_tool_call_loop`. Build `tool_specs` from chained iteration
      with the visibility filter applied. Replace the `find_tool` call
      at the execution site with an inline chain+filter lookup so
      hallucinated calls to filtered-out tools surface as "unknown
      tool" errors. Drop the now-unused `find_tool` import.
    - Update the legacy `agent_turn` wrapper to pass `None, &[]`,
      preserving its existing unfiltered behaviour.
    - Update all 9 in-file test sites to pass `None, &[]`.

  src/openhuman/agent/harness/tests.rs
    - Update all 3 `run_tool_call_loop` test sites to pass `None, &[]`.

  src/openhuman/agent/bus.rs
    - Add `target_agent_id: Option<String>`, `visible_tool_names:
      Option<HashSet<String>>`, and `extra_tools: Vec<Box<dyn Tool>>`
      fields to `AgentTurnRequest`, with rustdoc explaining each.
    - Destructure the new fields in the `agent.run_turn` handler;
      thread `visible_tool_names.as_ref()` and `&extra_tools` through
      to `run_tool_call_loop`. Augment the dispatch trace with
      target_agent / extra_tool_count / visible_tool_count /
      filter_active so production logs show whether scoping is active.
    - Update the in-test `test_request()` helper to populate the new
      fields with safe defaults.

  src/openhuman/agent/triage/evaluator.rs
    - Update the triage `AgentTurnRequest` initializer to set
      `target_agent_id = Some("trigger_triage")` (for tracing) with
      `visible_tool_names: None` + `extra_tools: Vec::new()` because
      the classifier intentionally runs against an empty registry and
      emits a structured JSON decision rather than calling tools.

  src/openhuman/channels/runtime/dispatch.rs
    - Update the channel-message `AgentTurnRequest` initializer to set
      the three new fields to safe defaults (`None` / `None` / empty
      vec). Commit 4b will replace these with the real onboarding-flag
      based routing.

  src/openhuman/tools/impl/agent/mod.rs
    - Bug fix: `dispatch_subagent` previously took `_skill_filter:
      Option<&str>` but discarded the value, hardcoding
      `SubagentRunOptions::skill_filter_override = None`. That meant
      `SkillDelegationTool::execute()` synthesising
      `dispatch_subagent("skills_agent", ..., Some("gmail"))` never
      actually narrowed `skills_agent`'s tool list — so even with the
      orchestrator's view scoped, the spawned `skills_agent` subagent
      would still see the full Composio catalog. Drop the underscore,
      propagate `skill_filter` into `skill_filter_override`, and add a
      tracing log line to make this path observable. This is the
      downstream half of the #526 leak that commit 3's orchestrator-
      side scoping alone wouldn't have caught.

Tests: 8/8 `tool_loop` tests pass, 3/3 harness `tests.rs` cases pass,
323/324 agent module tests pass overall (the one failure is the same
pre-existing Windows-path bug in `self_healing::tool_maker_prompt_
includes_command` that fails identically on the upstream baseline).
No existing test expectations were changed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(dispatch): route channel messages to welcome/orchestrator by onboarding flag (#525)

Wires the per-agent tool scoping plumbing from commit 4a into the
channel-message dispatch path. Each incoming channel message now picks
the active agent — `welcome` pre-onboarding, `orchestrator` post — based
on `Config::onboarding_completed`, loads the matching definition from
the global `AgentDefinitionRegistry`, synthesises any `delegate_*` tools
the agent declares in its `subagents` field, and passes everything
through to `agent.run_turn` on the bus.

This is the half of #525 that makes the welcome agent actually run for
new users — the welcome definition has existed since upstream PR #522
but had no caller; nothing in dispatch consulted the onboarding flag,
so every channel message ran through the same generic tool loop with
the full registry exposed.

Changes:

  src/openhuman/channels/runtime/dispatch.rs
    - New `AgentScoping` struct carrying the three new `AgentTurnRequest`
      fields (`target_agent_id`, `visible_tool_names`, `extra_tools`)
      plus an `unscoped()` constructor for safe-fallback paths.
    - New async `resolve_target_agent(channel)` helper:
      * fresh `Config::load_or_init().await` per turn (no cache — the
        loader reads from disk every call, verified at
        `config/schema/load.rs:409`, so the welcome→orchestrator
        handoff is observed on the next message after
        `complete_onboarding(complete)` flips the flag, with no need
        for an explicit handoff event);
      * picks `"welcome"` or `"orchestrator"` based on the flag and
        emits a structured `[dispatch::routing] selected target agent`
        info trace recording the choice + the flag value, satisfying
        the #525 acceptance criterion `"agent-selection logs clearly
        record why each agent was selected at onboarding boundaries"`;
      * looks up the definition in `AgentDefinitionRegistry::global()`,
        gracefully falling back to `AgentScoping::unscoped()` (= legacy
        behaviour, no filter, no extras) if the registry isn't
        initialised or the definition isn't found, so a routing miss
        never fails the user message;
      * for agents with a non-empty `subagents` field, awaits
        `composio::fetch_connected_integrations(&config)` and runs
        `orchestrator_tools::collect_orchestrator_tools` to materialise
        per-turn delegation tools (`research`, `plan`, `delegate_gmail`,
        …). Agents with empty `subagents` get an empty extras vec.
    - New `build_visible_tool_set(definition, &extra_tools)` helper that
      returns `Some(union)` for `ToolScope::Named` agents (their named
      list ∪ the names of the synthesised delegation tools) and `None`
      for `ToolScope::Wildcard` agents to preserve the unfiltered
      semantics — so agents like `skills_agent` and `morning_briefing`
      that already work via `wildcard + category_filter` keep their
      existing behaviour without this layer interfering.
    - `process_channel_message` calls `resolve_target_agent` once per
      turn, drops the placeholder defaults from commit 4a, and feeds
      the real `target_agent_id`/`visible_tool_names`/`extra_tools`
      into `AgentTurnRequest`.
    - New imports: `AgentDefinition`, `AgentDefinitionRegistry`,
      `ToolScope`, `Config`, `fetch_connected_integrations`,
      `orchestrator_tools`, `Tool`, `HashSet`.

End-to-end behaviour after this commit:

  1. New user, `onboarding_completed=false`: dispatch picks `welcome`,
     loads its 2-tool TOML scope, builds `visible_tool_names =
     {complete_onboarding, memory_recall}`, no extras, hands off to
     the bus. Bus handler applies the filter → welcome's LLM sees
     exactly 2 tools.
  2. Welcome agent guides the user through setup, eventually calls
     `complete_onboarding(action="complete")` → flag persists to disk
     via `config.save()`.
  3. Next user message: dispatch reads the flag fresh, picks
     `orchestrator`, fetches connected Composio integrations, expands
     `subagents = ["researcher", "planner", "code_executor", "critic",
     "archivist", { skills = "*" }]` into delegate_research /
     delegate_plan / delegate_run_code / delegate_review_code /
     delegate_archive_session + one delegate_<toolkit> per connected
     integration. visible_tool_names is the union with the 4 direct
     tools from orchestrator's `[tools] named` list. LLM sees the
     scoped delegation surface, not the full 1000+ Composio catalog.

#526's runtime leak is now fixed end-to-end: the orchestrator's LLM
prompt only contains the tools its TOML allows, and the
SkillDelegationTool path narrows skills_agent to a single toolkit via
the `skill_filter` propagation fix from commit 4a. No agent at any
layer sees more than its definition declares.

Tests: 599/599 channel module tests pass — including
`runtime_dispatch::dispatch_routes_through_agent_run_turn_bus_handler`
and the telegram integration variant, which exercise the full bus
roundtrip with the new fields populated. No existing assertions were
modified.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(debug_dump): apply orchestrator definition filter to main dump (#526)

Replaces the explicit `empty_filter: HashSet::new()` in
`render_main_agent_dump` with a real visibility whitelist derived from
the orchestrator's `AgentDefinition`. The "main" dump path now mirrors
the runtime dispatch path from commits 4a/4b — same definition, same
delegation tool synthesis, same filter — so `openhuman agent dump-prompt
main` shows what the LLM actually sees in production instead of the
unfiltered global registry.

Before this commit, `dump-prompt main` always rendered every tool in
the registry regardless of which agent was supposed to run, which is
exactly the symptom #526 reports: "agent prompt tool scopes leak full
GitHub tool catalog". The runtime fix in commit 4b stops the leak in
production but the dump path was the user's primary observability tool
for inspecting prompts, so leaving it unscoped would mask future
regressions and confuse debug sessions.

Changes in `src/openhuman/context/debug_dump.rs`:

  * `dump_agent_prompt` now loads `AgentDefinitionRegistry` once at the
    top (previously only loaded inside the sub-agent branch). When the
    request is for "main" or "orchestrator_main", it looks up the
    "orchestrator" definition from the registry and passes it into
    `render_main_agent_dump` along with the registry itself. Missing
    orchestrator entry → structured error listing known agents
    instead of silently rendering an unfiltered prompt.

  * `render_main_agent_dump` signature gains `registry:
    &AgentDefinitionRegistry` and `orchestrator_def: &AgentDefinition`.
    Inside, it:
      - calls `collect_orchestrator_tools(orchestrator_def, registry,
        connected_integrations)` to synthesise the same per-turn
        delegation tools (`research`, `plan`, `delegate_<toolkit>`,
        …) that dispatch generates;
      - extends `prompt_tools` with the synthesised extras so they
        contribute to the rendered tool catalogue;
      - builds `visible_filter: HashSet<String>` from
        `orchestrator_def.tools` (the `[tools] named` list) ∪ the
        names of the synthesised extras, falling back to an empty
        HashSet when the orchestrator definition uses
        `ToolScope::Wildcard` (which the prompt builder treats as "no
        filter, every tool visible") so dump consumers that supply a
        wildcard orchestrator (custom workspace overrides, tests)
        retain the legacy unscoped behaviour;
      - replaces `visible_tool_names: &empty_filter` in the
        `PromptContext` with `&visible_filter`;
      - filters the returned `tool_names` and `skill_tool_count` by
        the same predicate so the `DumpedPrompt` summary fields
        match what the prompt text actually contains.

Tests:

  * Replaces the previous `render_main_agent_dump_includes_tool_
    instructions_and_skill_count` test with two more focused cases:

    1. `render_main_agent_dump_wildcard_scope_shows_full_tool_set` —
       regression guard for the legacy wildcard path. Builds a
       wildcard-scoped orchestrator definition, asserts every tool
       from `tools_vec` survives, and checks the standard system-
       prompt skeleton (Tools section, Tool Use Protocol, cache
       boundary) still renders.

    2. `render_main_agent_dump_named_scope_filters_to_whitelist` —
       the #526 regression guard. Builds an orchestrator with
       `ToolScope::Named(["query_memory", "ask_user_clarification"])`
       and a `tools_vec` containing `shell`, `query_memory`, and
       `GMAIL_SEND_EMAIL`. Asserts the dump's `tool_names` is exactly
       `["query_memory"]` — `shell` and `GMAIL_SEND_EMAIL` are in the
       global registry but NOT in the whitelist, so they MUST be
       excluded. If a future change reintroduces the unfiltered
       behaviour this test fails immediately.

  * Adds two test helpers: `wildcard_orchestrator_def()` builds a
    minimal orchestrator definition with all `omit_*` flags set and
    `ToolScope::Wildcard`, and `registry_with_orchestrator(orch)`
    wraps it in an `AgentDefinitionRegistry` so the tests can call
    `render_main_agent_dump` without going through the full TOML
    loader.

11/11 debug_dump tests pass. The two new guards plus the 9 existing
sub-agent / filter / composio-stub tests all run clean.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* test(dispatch): unit tests for build_visible_tool_set + cargo fmt cleanup (#525, #526)

Adds focused unit tests for the per-agent scoping helper landed in
commit 4b and runs `cargo fmt` across the files touched by this PR.

Why scoping unit tests, not full integration tests:

  `resolve_target_agent` is async and reads `Config::load_or_init().await`
  which does a real disk read every call (no cache, verified at
  `config/schema/load.rs:409`). Mocking that requires either spinning up
  a full workspace under a temp dir with a config.toml containing the
  right `onboarding_completed` value, or adding a test-only injection
  point on the public Config API. Both are tractable but invasive
  enough to belong in their own follow-up PR. The end-to-end dispatch
  path is already covered by the existing channel integration tests
  (`dispatch_routes_through_agent_run_turn_bus_handler` etc.) which
  exercise the full bus roundtrip with the new fields populated, and
  which still pass after the new resolver landed (it gracefully falls
  back to `AgentScoping::unscoped()` when no orchestrator definition
  is registered in the test environment).

  Pure-function unit tests for `build_visible_tool_set` cover the
  branching logic that does the actual scoping work: how the named
  whitelist + extras union is built, how Wildcard scope is preserved,
  how duplicates are de-duplicated, etc. That's the part most likely
  to drift in future changes, so it's the part most worth fencing
  with focused tests.

Tests added (all in `src/openhuman/channels/runtime/dispatch.rs` under
the new `scoping_tests` module):

  * `wildcard_scope_yields_none_filter` — `ToolScope::Wildcard` must
    produce `None` regardless of whether extras are present, so
    skills_agent / morning_briefing keep their full skill-category
    catalogue.

  * `named_scope_without_extras_returns_named_only` — the welcome
    agent's path: 2 named tools, no delegation, exactly 2 entries in
    the visibility whitelist.

  * `named_scope_with_extras_returns_union` — the orchestrator's path:
    3 direct named tools + 3 synthesised extras (research,
    delegate_gmail, delegate_github) → 6 entries.

  * `empty_named_with_extras_returns_extras_only` — guards a future
    "delegation-only" agent layout where the agent has no direct tools
    of its own, just spawns subagents.

  * `empty_named_with_no_extras_returns_empty_set` — guards the
    distinction between `None` (no filter, all visible) and
    `Some(empty)` (filter active, nothing matches). Important because
    the prompt loop's `is_visible` check treats them differently.

  * `duplicate_names_across_named_and_extras_are_deduplicated` — the
    HashSet handles collisions automatically, but the test pins that
    behaviour so a future migration to `Vec<String>` (which would
    silently double-count) gets caught.

  * `agent_scoping_unscoped_has_no_filter_or_extras` — pins the
    safe-fallback constructor's contract. Used when the registry is
    uninitialised or the target agent is missing — every field must
    default to "no scoping" so the channel turn falls back to legacy
    unfiltered behaviour rather than crashing.

Plus `cargo fmt` run across the 6 files modified by this PR. No
behavioural changes.

Final test status across all commits 2-6 in this PR:

  * agent::harness::definition: 10/10  (4 new for Subagents schema)
  * agent::harness::tool_loop: 8/8 
  * agent::harness::tests: 3/3 
  * tools::orchestrator_tools: 5/5  (5 new)
  * channels::*: 599/599  (incl. dispatch integration)
  * channels::runtime::dispatch::scoping_tests: 7/7  (7 new)
  * context::debug_dump: 11/11  (1 replaced + 1 new)
  * Total agent module: 323/324 (one pre-existing Windows path
    failure in `self_healing::tool_maker_prompt_includes_command`
    confirmed identical against upstream/main baseline)

Pre-existing Windows-environment test failures NOT caused by this PR
and out of scope (all confirmed identical on upstream baseline; CI on
Linux is unaffected):

  * self_healing::tool_maker_prompt_includes_command (PathBuf separator)
  * cron::scheduler::run_job_command_success / _failure (Unix shell)
  * composio::trigger_history::archives_triggers_in_daily_jsonl... (path)
  * local_ai::paths::target_paths_preserve_absolute_overrides (path)
  * security::policy::checklist_root_path_blocked (POSIX absolute)
  * security::policy::checklist_workspace_only_blocks_all_absolute (POSIX)
  * tools::implementations::browser::screenshot::screenshot_command_
    contains_output_path (browser binary lookup)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(welcome): upgrade bare-install nudge with concrete integration pitches (#525)

Follow-up to #522 that addresses a rough UX edge in the welcome agent
prompt: users who arrive with only an API key configured (no
channels, no Composio integrations, no web search / browser / local
AI) were getting a "gentle suggestion" to connect something without
any concrete picture of what they'd actually unlock. The welcome
agent would finish onboarding, hand off to orchestrator, and leave
the user staring at a functional-but-empty assistant with no
roadmap for how to make it useful.

This commit rewrites the prompt to handle sparse setups explicitly.
No Rust changes — this is prompt.md only, picked up at build time
via the existing `include_str!` loader in `agents/mod.rs`.

Changes to `src/openhuman/agent/agents/welcome/prompt.md`:

  * Step 2's "point out what's missing" sub-point is rewritten from
    a single "gently suggest" line into a four-case decision tree
    keyed off `check_status` state:
      - No API key → critical, block completion.
      - Integrations yes, channels no → note the Tauri-only reach
        limitation, suggest a messaging platform.
      - Channels yes, integrations no → degraded assistant, nudge
        toward Composio.
      - Nothing beyond the API key → the "bare install" case, gets
        the new Step 2.5 treatment.

  * New **Step 2.5: Handling a bare install** section added after
    Step 2. Spells out what the user DOES have (sandboxed reasoning
    + coding assistant with memory), what they're MISSING (any
    external action), and how to structure the message: state the
    current capability honestly, pitch 2-3 specific integrations
    with concrete example prompts, point to Settings → Integrations
    / Channels, and leave room for the user to opt into the
    coding-only experience if that's what they actually want. For
    bare-install users the word budget stretches to 250-400 words
    (up from 200-350) so the concrete pitches and example prompts
    actually fit without cramming.

  * New **Integration capability reference** section giving the LLM
    a menu it can draw from when pitching integrations. Each entry
    is a one-line "connect X → I can Y" with a concrete example
    prompt the user could send next:
      - Gmail: "Summarise the most important emails that came in
        overnight and flag anything that needs a reply today."
      - Google Calendar: "What's on my calendar tomorrow, and do I
        have a 30-minute gap before 2pm?"
      - GitHub: "List open issues on my main project tagged 'bug'
        and summarise which ones look newest or most urgent."
      - Notion: "Pull up my 'Ideas' Notion database and show me
        the three newest entries."
      - Slack / Discord / Linear / Jira / etc. with similar shapes.

    Plus a sub-section for messaging platforms (Telegram / Discord /
    Slack / iMessage / WhatsApp / Signal / web-fallback) that
    clarifies which each is best for, and a sub-section for the
    other capabilities (web search, browser automation, HTTP
    requests, local AI) that explains what breaks without them.

    The LLM is told NOT to list everything — just pick 2-3 most
    likely to matter, defaulting to Gmail + GitHub + one of
    {Calendar, Notion} as the top-3 pitch when no profile context
    is available.

  * Tone guidelines updated to document the stretched word budget
    for bare installs (200-350 for configured users, 250-400 for
    bare installs).

  * "What NOT to do" list updated:
      - Explicitly allows product-tour-style listing ONLY in the
        bare-install case (Step 2.5), forbids it elsewhere.
      - Clarifies that describing what WOULD unlock with integration
        X is fine and encouraged; claiming a capability the user
        doesn't have is still forbidden.
      - Adds a new "Don't gloss over a bare install" entry that
        pins the rule: API-key-only users get concrete pitches and
        example prompts, not vague suggestions.

Scope note: this commit does NOT change the completion logic.
`complete_onboarding(complete)` still accepts API-key-only as the
minimum bar — that's a separate design question for the maintainer
about whether zero-integration users should be gatekept. This
change improves what the welcome agent SAYS to those users, not
whether they're allowed to proceed.

Tests: all 14 `agent::agents::tests` pass (including
`welcome_has_onboarding_and_memory_tools` which validates the
welcome agent's declarative shape is unchanged). The prompt.md
edit is pure content — no schema changes, no tool additions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(web-channel): route Tauri in-app chat to welcome/orchestrator + Windows fsync fix (#525)

Closes the web-channel-side half of the #525 welcome agent routing.
Also bundles a small pre-existing Windows compatibility fix for
`app_state::ops::sync_parent_dir` that was blocking end-to-end testing
of this branch on Windows.

## Web channel routing (primary change)

Commit 4b (274b50ed) wired the welcome-vs-orchestrator routing into
`channels/runtime/dispatch.rs::process_channel_message` — the handler
for inbound messages on external channels (Telegram, Discord, Slack,
iMessage, etc.). That path reads `config.onboarding_completed` and
selects the right agent via the bus.

End-to-end testing on the Tauri desktop app revealed a gap: the
in-app chat window does NOT route through `process_channel_message`.
It uses its own JSON-RPC method (`openhuman.channel_web_chat` →
`start_chat` → `run_chat_task` → `build_session_agent` →
`Agent::from_config`) which was hardcoded to build a generic
main-agent every turn. So a new user typing in the Tauri app saw the
orchestrator immediately, never the welcome agent.

This commit closes that gap without adding a third code path:

### `src/openhuman/agent/harness/session/builder.rs`

* Split the existing `Agent::from_config` into a thin wrapper + a
  new `Agent::from_config_for_agent(config, agent_id)`. The wrapper
  calls the new function with `agent_id = "orchestrator"`, preserving
  the legacy behaviour byte-identically for all existing callers
  (CLI, REPL, tests, every call site that doesn't care which agent
  they get).
* `from_config_for_agent` looks up `agent_id` in
  `AgentDefinitionRegistry::global()` up front; unknown ids fail
  fast with a clear error. Missing orchestrator is tolerated as a
  legacy / pre-startup fallback so existing tests that run without
  a populated registry continue to work.
* When a target definition is resolved:
    - `prompt_builder` uses `SystemPromptBuilder::for_subagent` with
      the agent's `system_prompt` body (read from the registry,
      which already has it inlined via `include_str!` from
      `agents/*/prompt.md` at crate-build time) and the three
      `omit_*` flags from its TOML.
    - `visible_tool_names` is built from the agent's
      `ToolScope::Named` list, unioned with the names of any
      synthesised delegation tools produced by
      `collect_orchestrator_tools` (for agents that declare
      `subagents = [...]`).
    - `ToolScope::Wildcard` yields an empty filter, matching the
      legacy "no filter, all visible" semantics.
    - `temperature` comes from the agent's TOML (e.g. welcome is
      0.7, orchestrator is 0.4) instead of
      `config.default_temperature`.
* Legacy behaviour for plain `from_config(config)` — which passes
  `agent_id = "orchestrator"` — is preserved: the prompt builder
  continues to use `SystemPromptBuilder::with_defaults()`, temperature
  comes from `config.default_temperature`, and the orchestrator's
  `subagents` list drives delegation tool synthesis exactly as
  before. No test expectations changed.

### `src/openhuman/channels/providers/web.rs`

* `build_session_agent` now reads `config.onboarding_completed` and
  picks `"welcome"` or `"orchestrator"` as the target agent id, then
  calls `Agent::from_config_for_agent`. Structured info log records
  the routing decision + the flag state for observability, matching
  the `[dispatch::routing]` trace pattern from Commit 4b.
* `config.onboarding_completed` is read fresh every turn because
  `run_chat_task` loads the config via
  `config_rpc::load_config_with_timeout`, which reads from disk on
  every call (no in-process cache). Welcome → orchestrator handoff
  therefore happens automatically on the next chat message after
  `complete_onboarding(complete)` flips the flag, with no explicit
  event or notification plumbing.
* `set_event_context` call is unchanged — the event context is a
  (session_id, channel_name) pair for telemetry, not the agent id.

## Windows fsync fix (bundled)

### `src/openhuman/app_state/ops.rs`

`sync_parent_dir` was calling `File::open(parent).and_then(|dir|
dir.sync_all())` on the save path for `app_state.json`. On Windows,
opening a directory as a regular file requires
`FILE_FLAG_BACKUP_SEMANTICS` which `std::fs::File::open` does not
set, so the call fails with "Access is denied. (os error 5)".

Mirrored the existing `#[cfg(unix)]` guard already in
`config/schema/load.rs::sync_directory`. On non-Unix the function is
a no-op and returns `Ok(())`. Durability on Windows is provided by
`NamedTempFile::persist`'s atomic `MoveFileEx`, which is sufficient
for config files.

This is a pre-existing upstream bug, not caused by this PR, but it
blocks end-to-end testing of any code path that touches
`app_state::save_stored_app_state_unlocked` on Windows — which
includes the web channel's agent_server_status RPC. Fixing it here
so the #525 routing can actually be tested on the developer's
machine, and the fix is tiny and self-contained. The `File` import
on line 1 is also gated behind `#[cfg(unix)]` to avoid an
unused-import warning on Windows.

## Tests

All 35 `agent::harness::session` tests pass, including the
transcript round-trip + session queue + runtime tests that exercise
the builder path most heavily. No test expectations were modified;
the refactor is a pure delegation chain (`from_config` → new inner
method → existing builder).

CLI dump-prompt testing from the earlier session still validates:
  * `openhuman agent dump-prompt welcome` → 2 tools, Step 2.5
    bare-install prompt embedded
  * `openhuman agent dump-prompt main` → 6 tools, zero skill leakage
  * `openhuman agent dump-prompt main --stub-composio` → still 6
    tools, composio meta-tools correctly filtered out

End-to-end Tauri testing (next step after this commit) will exercise
the new `build_session_agent` branch live — user types `hi`, web
channel routes to welcome, welcome replies with the updated
bare-install prompt from commit 990a7264.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(config): split chat_onboarding_completed from React UI onboarding flag (#525)

End-to-end testing on the Tauri desktop app revealed that the welcome
agent could never run from the in-app chat pane — even with all of
Commits 4b/8 in place — because the React layer's
`OnboardingOverlay.tsx` renders a full-screen wizard whenever
`onboarding_completed = false` and gates the chat pane behind it. By
the time a user can type a chat message the React wizard has already
flipped `onboarding_completed = true` (via `OnboardingOverlay::handleDone`
or the `Onboarding.tsx` wizard's completion handler), so the welcome
agent's routing condition is never satisfied on the Tauri surface.

This commit fixes the architectural conflict by splitting the single
`onboarding_completed` flag into two orthogonal flags:

* **`onboarding_completed`** — unchanged semantics. Tracks whether the
  React UI wizard has been completed/dismissed. Continues to be set
  by `OnboardingOverlay.tsx::handleDone` and `Onboarding.tsx` via the
  existing `config.set_onboarding_completed` JSON-RPC method. Used
  exclusively to gate whether the React layer renders the wizard.

* **`chat_onboarding_completed`** — NEW. Tracks whether the welcome
  agent's chat-based greeting flow has run. Set exclusively by the
  welcome agent itself via `complete_onboarding(action="complete")`.
  Used by both the Tauri web channel
  (`channels::providers::web::build_session_agent`) and the external
  channel dispatch path (`channels::runtime::dispatch::resolve_target_agent`)
  to decide whether to route to welcome or orchestrator.

The two flags are intentionally orthogonal:

  * A Tauri desktop user completes the React wizard → only
    `onboarding_completed` flips → wizard disappears → user types `hi`
    → welcome agent runs (because `chat_onboarding_completed` is still
    false) → welcome calls `complete_onboarding(complete)` →
    `chat_onboarding_completed` flips → next chat turn routes to
    orchestrator.

  * A Telegram/Discord user (no React wizard exists) sends a message
    → external channel dispatch checks `chat_onboarding_completed` →
    routes to welcome → welcome runs → flips the flag → next inbound
    message routes to orchestrator.

Both paths give every user the chat welcome experience, regardless of
which surface they came in through, and without requiring the React
wizard to be removed or restructured.

## Files changed

`src/openhuman/config/schema/types.rs`
* Add `chat_onboarding_completed: bool` field with `#[serde(default)]`
  for backward compat — existing `config.toml` files that don't have
  the field default to `false`, which means existing users will see
  the welcome agent on their next chat turn (correct behaviour, the
  welcome agent is idempotent).
* Default impl initializes the new field to `false`.
* Extensive rustdoc on both flags explaining the orthogonal split,
  why two flags exist, and which code paths gate on which.

`src/openhuman/tools/impl/agent/complete_onboarding.rs`
* `check_status` now reports BOTH flags side-by-side so the welcome
  agent's LLM can see whether the React wizard has run AND whether
  the chat welcome itself has run. Old single "Onboarding completed"
  line replaced with two lines: "UI onboarding wizard completed" and
  "Chat welcome flow completed".
* `complete()` now flips `chat_onboarding_completed`, NOT
  `onboarding_completed`. The React UI's flag is left untouched —
  that's owned by the React layer. Idempotency guard updated to
  check the chat flag.
* Rustdoc on `complete()` explains the orthogonal-flags rationale
  for future readers.

`src/openhuman/channels/providers/web.rs::build_session_agent`
* Reads `effective.chat_onboarding_completed` instead of
  `effective.onboarding_completed` for the welcome-vs-orchestrator
  decision.
* Log line now includes BOTH flags so observability captures the
  full picture (e.g. `chat_onboarding_completed=false,
  ui_onboarding_completed=true` is the expected steady state for a
  Tauri user who completed the React wizard but hasn't typed yet).

`src/openhuman/channels/runtime/dispatch.rs::resolve_target_agent`
* Same flag swap for the external-channel path.
* `[dispatch::routing] selected target agent` info trace also
  reports both flags.
* Function-level rustdoc updated with the new semantics.

## Backward compatibility

* Existing `config.toml` files: `chat_onboarding_completed` defaults
  to `false` via `#[serde(default)]`. Means existing users get a
  welcome message on their next chat turn — this is the desired
  behaviour, not a regression.
* React layer: untouched. The `config.set_onboarding_completed`
  JSON-RPC method continues to write the same field; the new flag is
  not exposed to React at all.
* External callers of `complete_onboarding(complete)`: they now flip
  a different flag. This may matter for callers who were depending
  on the flag flip side effect; a quick grep shows
  `tools/impl/agent/complete_onboarding.rs` is the only caller and
  the rest of the codebase reads `onboarding_completed` for UI
  purposes (snapshots, app state) and `chat_onboarding_completed`
  for routing — the split is clean.

## Tests

399/400 tools tests pass. The single failure
(`tools::impl::browser::screenshot::screenshot_command_contains_output_path`)
is a pre-existing Windows-environment bug that fails identically on
`upstream/main` baseline and is unrelated to this PR. No test
expectations were modified.

## Next step

Restage sidecar, relaunch Tauri, click through the React wizard once
to dismiss it, then type `hi` in the chat pane. This time
`build_session_agent` should read `chat_onboarding_completed=false`
and route to welcome, even though `onboarding_completed=true` was set
by the React layer.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(welcome): force tool-call-first iteration to stop greeting fallback (#525)

End-to-end testing on the Tauri desktop app revealed that the welcome
agent — even with all routing/scoping commits in place and the
correct prompt embedded — was producing 1-line greetings like
"Hey! What's up?" (15 chars, single iteration, zero tool calls)
instead of running its workflow. The user typed `hihi`, the LLM saw
the welcome agent's full ~14KB persona prompt, and chose to ignore
every workflow step in favour of the chatbot fallback behaviour.

Diagnosis: the previous prompt was descriptive ("Call check_status to
get a snapshot...") rather than imperative. Combined with a "Concise"
tone guideline and a low-information user input ("hihi"), the model
under tension between "be warm and concise" and "follow the workflow"
collapsed to "tiny greeting" — which is the chatbot training-data
default for short user messages.

This commit makes the workflow non-negotiable by:

## 1. New "MANDATORY FIRST ACTION" preamble at the top of the prompt

Inserted as a blockquote between the role description and "Your
workflow" so the model encounters it before any workflow steps. The
preamble:

* States explicitly that the **first thing emitted on every turn must
  be a tool call** to `complete_onboarding(check_status)` — before
  any user-facing text, before any greeting, before any thinking out
  loud.
* States the user's input is **irrelevant** to this rule: "hi",
  "hello", emoji, nothing — the first iteration always emits the
  same thing, a check_status tool call.
* Explains *why* the rule exists (without a status snapshot the
  agent has nothing to personalise on, and any blind greeting
  defeats the welcome agent's one job).
* Shows three concrete  wrong examples (generic chitchat reply,
  greeting-then-tool, refusing the tool because the user said hi)
  and one  correct example (first iteration emits ONLY the tool
  call, message comes in iteration 2).
* Closes with a stop-and-correct rule: "If you find yourself about
  to write any text in your first iteration, STOP. Emit the tool
  call instead."

## 2. Strengthened "Your workflow / Step 1" heading

Renamed to "Step 1: Check setup status (ALWAYS — see Mandatory First
Action above)" so the cross-reference is unmissable. Body explicitly
says "In your **first iteration**, ... No text. No greeting. Just
the tool call. ... You will use this report to write the actual
welcome message in your second iteration."

## 3. Length is non-negotiable (rewritten "Concise" tone guideline)

The previous "Concise — but scale with the situation" framing was the
exact phrase the model latched onto when producing 15 chars. Rewritten
to "Length is non-negotiable" and adds:

* "A 1-2 sentence greeting is a failure, not a 'concise' success."
* Explicit "if you ever produce a message under 100 words, stop and
  try again — you've almost certainly skipped a required element."
* A "concise vs. terse" callout distinguishing "no wasted words in a
  message that does its full job" from "skip the job entirely."

## 4. New "What NOT to do" entries

Three new bullets target the exact failure modes observed:

* The existing "Don't skip check_status" entry is upgraded with
  "**This is the single most common failure mode**" and a pointer
  back to the mandatory-first-action preamble.
* New "Don't reply with a 1-line greeting" entry forbids the chatbot
  fallback explicitly and sets a 100-word floor.
* New "Don't treat 'hi' / 'hello' / short greetings as a signal to
  be brief" entry explicitly disconnects user input length from
  agent output length, since "hi" is the most common opening and
  the user typing it needs the FULL welcome experience.

## What this does NOT change

* No Rust code changes. `prompt.md` is `include_str!`'d into the
  binary at crate-build time via `agents/mod.rs`, so the next sidecar
  rebuild picks up the new content automatically.
* No agent definition changes (`agent.toml` untouched). The welcome
  agent still has 2 tools (`complete_onboarding` + `memory_recall`),
  `temperature = 0.7`, `max_iterations = 6`, `omit_*` flags
  unchanged.
* No model hint change. Still `"agentic"`. If the new prompt language
  alone isn't enough to push the model over the workflow-following
  threshold, a follow-up commit can bump to `"reasoning"` for
  stronger instruction-following.
* The integration capability reference, Step 2.5 bare-install
  handling, subscription/referral flow, and handoff sections are
  unchanged from commit 990a7264. Those were never the problem —
  the problem was the model never reaching them because it skipped
  Step 1 entirely.

## Tests

14/14 `agent::agents::tests` pass, including
`welcome_has_onboarding_and_memory_tools` and `every_builtin_has_a_
prompt_body`. The structural shape of the welcome agent definition
is byte-identical; only the embedded prompt body changed.

## Verification plan

After restaging the sidecar and relaunching Tauri, type `hi` in the
chat pane. Expected behaviour:

1. `[web-channel] routing chat turn to 'welcome'` (already validated
   in commit 56d95e2c).
2. `[agent::builder] building session agent id=welcome` (already
   validated).
3. **`[agent_loop] iteration start i=1`** with a `check_status` tool
   call as the first emission — this is the new behaviour we're
   verifying.
4. `[agent_loop] iteration start i=2` after the tool returns, with
   the actual welcome message — should be 100-400 words covering all
   required elements.
5. If everything's in place, `complete_onboarding(complete)` call in
   the same turn or a later iteration → `chat_onboarding_completed`
   flips to `true` → next chat turn routes to orchestrator.

If iteration 1 still produces text instead of a tool call, the next
escalation is bumping the model hint from `"agentic"` to
`"reasoning"` in `agent.toml`.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(complete_onboarding): check session JWT in addition to legacy api_key (#525)

Discovered during end-to-end Tauri testing that the welcome agent
refused to call `complete_onboarding(complete)` for a fully
authenticated user because `check_status` reported "API key:
**missing**" — even though the user had completed the desktop OAuth
flow and a valid encrypted JWT was sitting in `auth-profiles.json`.

## Root cause

`check_status` was reading only `config.api_key` (an `Option<String>`
on the Config struct), which is a **legacy free-form provider key
field**. It is not where the desktop OAuth flow stores its
credentials. The actual openhuman backend session JWT lives in
`<openhuman_dir>/auth-profiles.json` under the `app-session:default`
profile, encrypted via the SecretStore using `<openhuman_dir>/.secret_key`,
and is read at every inference RPC via
`crate::api::jwt::get_session_token(config)` (which delegates to
`AuthService::from_config(config).get_profile(APP_SESSION_PROVIDER, None)`).

So a user who:
1. Completed the desktop deep-link OAuth flow (the only supported
   way to get an account), and
2. Has a fully populated `auth-profiles.json` with a valid encrypted
   `app-session:default` token,

was reported by `check_status` as "API key missing" because their
JWT lives in the auth profile store, not in `config.api_key`. The
welcome agent then dutifully refused to call `complete_onboarding(
complete)` per its own prompt rule ("If critical setup is missing,
do not complete onboarding"), and re-ran on every chat turn forever
even though there was nothing to fix.

This is a **pre-existing upstream bug** dating from PR #522 (the
welcome agent's introduction). It was written before, or in parallel
with, the auth-profile refactor that moved session credentials out of
`config.api_key` into the dedicated profile store. The check was
never updated to reflect the new auth model.

## Fix

`check_status` now checks BOTH sources:

```rust
let has_legacy_api_key = config.api_key.as_ref().map_or(false, |k| !k.is_empty());
let has_session_jwt = crate::api::jwt::get_session_token(&config)
    .ok()
    .flatten()
    .is_some_and(|t| !t.is_empty());
let is_authenticated = has_legacy_api_key || has_session_jwt;
```

The status report now shows:
* `Authentication: configured ✓ (session token from desktop login)` —
  when the user has gone through the OAuth flow (the typical case);
* `Authentication: configured ✓ (legacy api_key)` — when the user
  has set the legacy `config.api_key` field directly (CI / dev
  setups);
* `Authentication: **missing** — log in via the desktop app or set
  `api_key` in config to enable inference` — when neither source has
  a credential.

The line label changed from "API key" to "Authentication" because
"API key" was misleading for both states (it isn't really the user's
API key; it's the openhuman backend session JWT).

## What this unblocks

After this fix, a Tauri user who:
1. Logs in once via the desktop OAuth flow → JWT lands in
   `auth-profiles.json`,
2. Types `hi` in the chat pane → welcome agent runs,
3. Welcome calls `check_status` → reports "Authentication: configured ✓",
4. Welcome calls `complete_onboarding(complete)` → flips
   `chat_onboarding_completed` from false to true,
5. Next chat turn → routes to orchestrator.

Without this fix, step 4 never happens because welcome's prompt
explicitly forbids completing without an API key, so the user gets
the welcome message on every single turn forever.

## Files touched

`src/openhuman/tools/impl/agent/complete_onboarding.rs`
* `check_status` reads both `config.api_key` and
  `crate::api::jwt::get_session_token(&config)`.
* Status line renamed from "API key" to "Authentication" with a
  more informative message.
* Long inline comment documenting the two auth sources, why the
  check needs to consult both, and what bug was being fixed.

## Tests

360/361 tools tests pass. Only failure is the pre-existing Windows
browser-screenshot bug (`tools::impl::browser::screenshot::
screenshot_command_contains_output_path`) which fails identically on
upstream/main and is unrelated to this PR. The
`complete_onboarding::tool_metadata` test still passes — it only
checks the tool schema, not the runtime behaviour.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(welcome): force complete_onboarding(complete) in iteration 2 (#525)

Live Tauri test after Commit 11 (auth-aware check_status) showed the
welcome agent completing iteration 1 with the correct check_status
tool call AND iteration 2 with a beautiful 1586-char welcome message
— but NOT calling complete_onboarding(action="complete") to flip
chat_onboarding_completed. So the user gets the welcome message and
then routes to welcome AGAIN on every subsequent chat turn forever,
because the prompt's Step 3 is descriptive rather than imperative.

Same failure pattern as the iteration-1 chitchat fallback that
Commit 10 fixed — the LLM follows the path of least resistance. If
the prompt says "call X to finalize" without making it mandatory,
the model will write the welcome text, see that it's done its job,
and stop without emitting the second tool call.

Fix: rewrite Step 3 with the same "MANDATORY" preamble pattern that
worked for the iteration-1 fix. Specifically:

* Renamed Step 3 to "Complete onboarding — MANDATORY in iteration 2
  (when authenticated)" so the cross-reference is unmissable.
* Added a blockquoted "MANDATORY SECOND TOOL CALL" preamble at the
  top of Step 3 that:
  - States the rule: when check_status reports "Authentication:
    configured ✓", iteration 2 MUST contain BOTH the welcome message
    text AND a complete_onboarding(action="complete") tool call.
  - Explains the consequence: without the tool call, the user is
    routed to welcome forever, which is a hard failure.
  - Defines the iteration 2 output structure as a 2-element list:
    welcome text + tool call.
  - Shows / examples (welcome message without tool call vs. with
    tool call) so the model has a concrete pattern to match.
  - Documents the single exception: only when authentication is
    missing should complete() be skipped, and explicitly says
    "missing channels, missing Composio integrations, missing local
    AI — none of those block completion." Auth is the only blocker.
* Replaced the descriptive bullet list with a stricter "Decision
  rule for iteration 2" that pairs each check_status outcome with
  exactly what the agent must emit.

## What stays the same

* No code changes — the existing complete_onboarding tool already
  handles the action="complete" call correctly (Commit 11 made it
  flip the right flag and check the right auth source).
* No agent.toml changes — welcome still has 2 tools, max_iterations=6,
  temperature=0.7. Plenty of headroom for the 2-iteration flow.
* The decision logic itself is unchanged — auth → complete, no auth
  → no complete. Just the framing.

## Test plan

Restage sidecar, relaunch Tauri, type `hi`. Expected:

```
i=1: complete_onboarding(check_status) → "Authentication: configured ✓"
i=2: 1500-2000 char welcome message + complete_onboarding(action="complete")
       ↓
     [complete_onboarding] chat welcome flow marked complete, proactive agents seeded
       ↓
     chat_onboarding_completed flips false → true
```

Then type a follow-up message → expect:

```
[web-channel] route → orchestrator (chat_onboarding_completed=true)
```

That's the welcome → orchestrator handoff — the actual goal of #525.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(web-channel): include target_agent_id in THREAD_SESSIONS cache key (#525)

Final bug discovered during E2E testing: after Commit 12 made the
welcome agent successfully call complete_onboarding(complete) and
flip chat_onboarding_completed to true, the user typed a follow-up
message — but the next turn STILL routed through the welcome agent
instead of orchestrator.

## Root cause

`run_chat_task` caches the built `Agent` in a `THREAD_SESSIONS`
HashMap keyed by `(client_id, thread_id)`. The cache hit predicate
checked `model_override` and `temperature` for invalidation but not
the routing target — so when the routing decision flipped
(chat_onboarding_completed: false → true) between turns, the cache
happily returned the stale welcome agent and `build_session_agent`
was never invoked.

This was a real bug introduced by Commit 8 — when I added
agent-aware routing to `build_session_agent`, I should have
extended the cache key (or hit predicate) with the target agent id.
Without that, the routing fix only worked on the FIRST turn of any
thread; every subsequent turn served the cached agent regardless of
flag changes.

Symptoms in the live test:
* Turn 1: routes to welcome ✓, welcome runs 3 iterations, calls
  complete(), flag flips on disk → cached as welcome agent
* Turn 2: cache hits on (client_id, thread_id), returns cached
  welcome agent. NO `[web-channel]` routing trace, NO
  `[agent::builder]` trace. Welcome runs again with history_len=10.
* Result: orchestrator handoff never happens. User stuck in welcome
  forever.

## Fix

Three small changes in `web.rs`:

### 1. `SessionEntry` gains a `target_agent_id` field

```rust
struct SessionEntry {
    agent: Agent,
    model_override: Option<String>,
    temperature: Option<f64>,
    target_agent_id: String,  // NEW
}
```

Documents which agent definition was used to build the cached
`Agent`, so the next turn's cache lookup can compare against the
current routing decision.

### 2. New `pick_target_agent_id(config)` helper

```rust
fn pick_target_agent_id(config: &Config) -> &'static str {
    if config.chat_onboarding_completed {
        "orchestrator"
    } else {
        "welcome"
    }
}
```

Mirrors the routing decision inside `build_session_agent` so
`run_chat_task` can compute it once up front and use it as a cache
key component. Since `Config::load_or_init` reads from disk every
call, the value reflects the freshly persisted state — meaning the
moment the welcome agent flips the flag, the next turn's
`pick_target_agent_id` returns the new value, the cache hit
predicate falls through, and `build_session_agent` is invoked.

### 3. Cache hit predicate extended

```rust
let mut agent = match prior {
    Some(entry)
        if entry.model_override == model_override
            && entry.temperature == temperature
            && entry.target_agent_id == target_agent_id =>
    {
        log::info!("[web-channel] reusing cached session agent id={} ...");
        entry.agent
    }
    Some(prior_entry) => {
        log::info!(
            "[web-channel] cache miss — rebuilding session agent \
             (was id={}, now id={}) ...",
            prior_entry.target_agent_id, target_agent_id
        );
        build_session_agent(...)?
    }
    None => build_session_agent(...)?,
};
```

The `Some(prior_entry)` arm now distinguishes stale-cache hits
(rebuild + log the transition) from cold misses (rebuild silently).
The transition log line gives observability for the welcome →
orchestrator handoff: whenever you see "cache miss — rebuilding
session agent (was id=welcome, now id=orchestrator)", that's the
moment of the handoff in production.

## Tests

Library compiles. The cache-key change is small and the logic is
straightforward; no test changes needed (existing tests don't
exercise the `THREAD_SESSIONS` cache flow, and adding a unit test
would require mocking the global config + memory backend which is
significantly more setup than the change itself warrants).

Live verification: type `hi` (welcome), wait for the "all set"
message + flag flip, type a follow-up. Expected logs:

```
[web-channel] routing chat turn to 'welcome'  (turn 1)
[agent::builder] building session agent id=welcome
... welcome runs, calls complete(), flag flips ...

[web-channel] routing chat turn to 'orchestrator'  (turn 2)
[web-channel] cache miss — rebuilding session agent (was id=welcome, now id=orchestrator)
[agent::builder] building session agent id=orchestrator
```

That's the complete welcome → orchestrator handoff that has been
the goal of #525 since day one.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(complete_onboarding): document tool semantics in schema, terse success result (#525)

Stopping the prompt-bloat cycle on the welcome agent. Previous
commits (10, 12, and a half-applied iteration of 14) progressively
added MANDATORY blockquotes to welcome's prompt.md to fix three
distinct failure modes: chitchat-fallback in iter 1, missing
complete() in iter 2, and prose-leak in iter 3. Each fix worked but
the welcome prompt is now ~250 lines of imperatives encoding what
should be tool semantics, not agent persona.

The right home for tool semantics is the tool's own schema —
specifically `Tool::description()` and `parameters_schema()`. Those
fields are what the LLM sees when deciding when and how to call the
tool, and they apply to every agent that uses the tool, not just
welcome. Encoding the contract there lets the welcome prompt stay
about persona / workflow / tone, not implementation details.

## Changes

### `tools/impl/agent/complete_onboarding.rs`

#### `Tool::description()` — full rewrite

Replaces the previous 5-line description with a structured ~30-line
contract documenting BOTH actions:

* **`check_status`** — explicit list of what the report contains
  (auth, default model, channels, integrations, memory, both
  onboarding flags), explicit "side effects: NONE (read-only)"
  declaration, and explicit framing as "intended for an LLM agent
  to read and use as the basis for a personalized welcome message"
  so consumers know how to use the result.

* **`complete`** — explicit semantics: flips
  `chat_onboarding_completed`, triggers welcome→orchestrator
  handoff, seeds proactive cron jobs, idempotent, writes config.toml,
  has a pre-condition (must be authenticated). And critically:

  > The complete action returns the literal token "ok" on success.
  > **This return value is a machine-readable success marker, not
  > user-facing prose.** Do not paraphrase it, summarize it, or
  > acknowledge it back to the user — the actual user-facing welcome
  > text should have been emitted alongside the tool call in the
  > same iteration. The chat layer extracts the LAST iteration's
  > text as the user-visible reply, so any prose written after this
  > tool returns will overwrite the welcome message in the chat pane.

  This is the contract that prevents the iter-3 paraphrase leak,
  documented at the source of the tool result instead of in every
  consumer's prompt.

#### `parameters_schema()` — extended action description

The `action` enum's description now mirrors the contract:
> "check_status" → read-only inspection ... "complete" → finalize
> the chat welcome flow, flips chat_onboarding_completed to true,
> returns the literal token "ok" (NOT a user-facing message — do
> not paraphrase the result back to the user).

JSON-schema descriptions are seen by the LLM at function-calling
decision time, so the warning lands in the same place the LLM is
deciding whether to call the tool.

#### `complete()` return value

Changed from a 118-char chatty success string ("Chat welcome flow
marked as complete. Morning briefing and proactive agent jobs have
been set up. The user is all set!") to the literal 2-char `"ok"`.

The chatty string was the source of the iter-3 paraphrase leak —
the LLM kept treating it as something to summarize back to the user
in iteration 3, which overwrote the iter-2 welcome message in the
chat pane (because the chat layer extracts the last iteration's
text). With "ok" there's nothing to paraphrase.

The inline comment block on the return statement records the bug
history so a future maintainer who sees `Ok(ToolResult::success("ok"))`
and is tempted to make it more "informative" understands why it's
deliberately terse.

### `agents/welcome/prompt.md`

Reverts the half-applied "Step 6: STOP after iteration 2" blockquote
that I started adding in the previous commit attempt. Replaces it
with a single one-line pointer at the end of Step 5:

> "(See the `complete_onboarding` tool's own description for what
> its `"ok"` return value means and why you should not paraphrase it
> back to the user.)"

That's enough context for the welcome agent to understand the
return-value contract without duplicating the contract text. The
contract lives in the tool, not in the agent that consumes it.

## What this DOESN'T touch

* Commits 10 (mandatory first action) and 12 (mandatory second tool
  call) stay as-is. They're still arguably too forceful, but they
  encode workflow steps specific to the welcome agent's persona,
  not general tool semantics. Trimming them is a separate prompt
  audit follow-up.

* No code changes outside `complete_onboarding.rs`. No agent.toml
  changes, no schema changes, no other tool changes. Pure
  documentation + return-value tweak.

## Tests

`tool_metadata` still passes (it pins name, scope, permission_level,
and schema shape — not description text, which is intentionally
allowed to drift). 1/1 complete_onboarding tests pass. Library
compiles clean.

## Test plan

Restage sidecar, relaunch Tauri, type `hi`. Expected:
* iter 1: check_status → 600-char status report
* iter 2: 1500-char welcome message + complete_onboarding(complete)
* iter 3 (if it fires): no prose, or trivial prose ≪ iter 2 chars
* Chat pane shows the iter-2 welcome message, NOT a confirmation
  paraphrase.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(complete_onboarding): merge check + finalize, return JSON, halve welcome prompt (#525)

End-to-end testing surfaced the cleanest version of this design,
collapsing what had grown into a multi-iteration imperative dance
back into a single tool call with a JSON return value. Three
problems addressed in one pass:

## Problem 1 — two tool calls were a state-machine race condition

Previous design required the welcome agent to call two tools in
order: `complete_onboarding(check_status)` in iteration 1, then
`complete_onboarding(complete)` in iteration 2 alongside the welcome
text. This created a fragile state machine: if the model wrote the
welcome message but forgot the second tool call, the chat_onboarding
flag never flipped and the user got stuck in welcome forever. The
prompt grew a 50-line "MANDATORY SECOND TOOL CALL" blockquote with
multiple / examples to compensate for what was really an API
shape problem.

## Problem 2 — Markdown report fed paraphrase loops

`check_status` returned a 600-char human-readable Markdown report
with section headers, bullet points, and check marks. The LLM kept
treating it as a draft and paraphrasing fragments back into the
welcome message instead of using the field values as ground-truth
facts. Worse, the structured-looking output gave the model
permission to "wrap up" the tool result in iteration 3, producing
the (parenthetical) leak text the user kept seeing.

## Problem 3 — prompt was bloated past the point of usefulness

After three rounds of "the LLM did the wrong thing, add a more
forceful blockquote", the welcome prompt was ~250 lines, with
overlapping MANDATORY blocks, repeated / examples, and
imperatives that contradicted the tone guidelines. A model trying
to serve a coherent welcome persona could not emerge from that.

## Fix — single call, JSON return, auto-finalize as a side effect

`check_status` now does all of:

1. Reads the user's config.
2. Checks JWT via `crate::api::jwt::get_session_token` (the auth
   source the rest of the codebase uses).
3. **If authenticated AND chat_onboarding_completed is false, flips
   the flag + seeds proactive cron jobs as a side effect**. No
   second tool call, no parameter, no opt-in. Authentication is the
   only gate.
4. Returns a structured JSON snapshot:
   ```
   {
     "authenticated": true,
     "auth_source": "session_token",
     "default_model": "reasoning-v1",
     "channels_connected": ["telegram"],
     "active_channel": "web",
     "integrations": {
       "composio": false,
       "browser": false,
       "web_search": true,
       "http_request": false,
       "local_ai": true
     },
     "memory": { "backend": "sqlite", "auto_save": true },
     "delegate_agents": [],
     "ui_onboarding_completed": true,
     "chat_onboarding_completed": true,
     "finalize_action": "flipped"
   }
   ```

The `finalize_action` field discriminates three cases the welcome
agent needs to handle differently:

* `"flipped"` — auth ok, first welcome, flag was just flipped by
  this call. Write the full welcome and hand off.
* `"already_complete"` — auth ok, chat flow already done from a
  prior call. Friendly re-entry; still write a welcome but
  acknowledge they've been here before.
* `"skipped_no_auth"` — not authenticated. Don't write a celebratory
  welcome; explain the auth problem and let them retry on the next
  chat turn.

The `complete` action stays as a legacy manual finalize-only escape
hatch for admin tools / tests. Returns "ok". Welcome agent doesn't
use it.

## Welcome prompt rewrite

Down from ~250 lines to ~140. Specifically removed:

* "MANDATORY FIRST ACTION" blockquote (40 lines) — replaced with
  one tight paragraph in the new "Iteration 1: call
  complete_onboarding" section.
* "MANDATORY SECOND TOOL CALL" blockquote (50 lines) — entirely
  gone. There is no second tool call.
* "STOP after iteration 2" Step 6 from a previous failed iteration
  (never landed in this branch but the structure that motivated it
  is gone too).
* "Decision rule for iteration 2" (5 lines) — replaced with a
  three-bullet list keyed off `finalize_action`.
* Three duplicate "Don't reply with a 1-line greeting" / "Don't
  treat 'hi' as a signal to be brief" rules — collapsed into one
  shorter rule.

Added:

* "You have exactly two iterations. There is no third." — single
  declarative sentence that does the work the previous 90 lines of
  iteration imperatives were doing.
* Explicit framing that JSON is a fact source, not a draft. "Don't
  quote, paraphrase, or summarise the JSON snapshot back to the
  user."
* `finalize_action` decision tree (three bullets) so the agent
  knows what message to write for each case without the prompt
  needing to enumerate side effects.
* Handling for `skipped_no_auth` — short, helpful "you need to log
  in first" message instead of the celebratory welcome.

## Why this works without any iteration-2 imperatives

With a single tool call, the agent loop converges naturally:

* iter 1: tool call (the only thing the prompt allows)
* tool returns: JSON snapshot, flag already flipped server-side
* iter 2: prose response (no remaining tool calls to make)
* iter 3: doesn't fire because there's no incomplete work — the
  loop terminator at `turn.rs:351` returns when `calls.is_empty()`,
  which is iter 2's state.

No race, no forgotten flip, no paraphrased tool result, no
parenthetical leak.

## Files

`src/openhuman/tools/impl/agent/complete_onboarding.rs` — full
rewrite. New `check_status` builds the JSON, performs the auto-
finalize side effect, returns serialised JSON via `ToolResult::
success`. Old Markdown-report code deleted (~150 lines). Tool
description and `parameters_schema` rewritten to document the new
contract; the `finalize` parameter from the previous attempt is
removed entirely (auth presence IS the gate).

`src/openhuman/agent/agents/welcome/prompt.md` — full rewrite.
~250 → ~140 lines. New "two iterations, no third" workflow.
JSON-aware drafting instructions. `finalize_action` decision tree.

## Tests

* `tool_metadata` (1/1): pins schema shape (action enum, required
  fields). Still passes — schema unchanged at the level the test
  asserts.
* `agent::agents::*` (14/14): all welcome-definition shape tests
  pass. The TOML hasn't changed, only the embedded `prompt.md`.

## Verification

Restage sidecar, relaunch Tauri, type `hi`. Expected:

* iter 1: `complete_onboarding(check_status)` → JSON returned with
  `finalize_action: "flipped"`. Side effect: chat_onboarding
  flipped to true on disk.
* iter 2: ~250-word welcome message based on the JSON.
* iter 3: not reached. Loop terminates after iter 2.
* Chat pane shows the iter-2 welcome. No paraphrase leak.

Then type a follow-up:

* `[web-channel] cache miss — rebuilding session agent (was
  id=welcome, now id=orchestrator)` — the welcome → orchestrator
  handoff fires via Commit 13's cache invalidation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(welcome): hide the welcome→orchestrator handoff from the user (#525)

The welcome agent's prompt previously instructed it to close every
welcome message with an explicit handoff line:

> "From here, you're in the hands of the full OpenHuman assistant.
>  Just start a new conversation and ask it anything — it knows how
>  to delegate to specialists, run tools, search the web..."

That line is a UX leak. From the user's perspective they are
talking to "OpenHuman" — one assistant, one conversation. They do
not know there are multiple agents under the hood, and they
shouldn't have to. The welcome → orchestrator transition is a
routing-layer implementation detail. Surfacing it in the chat
makes openhuman feel like a chatbot framework instead of a
product.

## Changes (welcome prompt only — no code)

### Step 5 of the message structure

Renamed from "Hand off" to "Close naturally". New instruction:

> "End the message with something inviting like 'anything you'd
>  like to try first?' or 'what should we dig into?' — a normal
>  conversational close that lets the user pick up the next turn.
>  Do NOT mention handing off, transferring, or any change in who
>  they're talking to. The user does not know there are multiple
>  agents under the hood; from their perspective they're talking
>  to OpenHuman as one entity, and the next message they send
>  will just continue the conversation."

### "What NOT to do" list

Replaced the old "Don't forget the handoff" rule with its inverse:

> "**Don't reveal that the conversation is being routed to a
> different agent.** From the user's perspective they are talking
> to 'OpenHuman' — one assistant, one conversation. Do NOT say
> 'I'll hand you off to the main assistant', 'the orchestrator
> will take over', 'you're now in the hands of the full assistant',
> 'from here on out you'll be talking to a different agent', or
> any variation that exposes the welcome → orchestrator handoff.
> The handoff happens transparently in the routing layer; the user
> just sends another message and the conversation continues.
> Phrases like 'what should we dig into?' or 'anything you'd like
> to try first?' are correct conversational closes — they invite
> the next turn without leaking the architecture."

The rule lists four specific forbidden phrasings the previous
welcome agent runs produced (variations of "you're in the hands
of the full assistant" and "the orchestrator will take over") so
the model has explicit anti-patterns to match against, plus two
acceptable conversational closes.

## Why this matters for #525

The welcome → orchestrator handoff is the central architectural
contribution of this PR. From a developer's perspective it is a
big deal — different agent definition, different tool scope,
different prompt, different temperature, different model hint.
From the user's perspective it should be invisible. They type a
message, OpenHuman replies. They type another, OpenHuman replies.
The fact that one of those replies came from `welcome` and the
next from `orchestrator` is purely an implementation detail they
should never have to think about.

## What does NOT change

* No code changes. Pure prompt edit.
* The handoff still happens — `chat_onboarding_completed` still
  flips to true via the `check_status` auto-finalize, the
  `THREAD_SESSIONS` cache still invalidates between turns (Commit
  13), the next chat turn still routes to orchestrator (Commit 8).
  The mechanism is unchanged; only the user-facing framing of it
  is.
* No test changes. `agent::agents::welcome_has_onboarding_and_
  memory_tools` and `every_builtin_has_a_prompt_body` only
  validate the agent definition shape, not the prompt body text,
  so this commit does not affect them.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor(complete_onboarding): delegate auto-finalize side effect to complete() (#525)

Small refactor on top of Commit 15. The auto-finalize block inside
`check_status` previously inlined the entire flag-flip + cron-seed
sequence:

```rust
config.chat_onboarding_completed = true;
config.save().await?;
let seed_config = config.clone();
tokio::spawn(async move {
    if let Err(e) = crate::openhuman::cron::seed::seed_proactive_agents(&seed_config) {
        tracing::warn!("...");
    }
});
```

But the legacy `complete()` action does literally the same work.
Duplicating it meant two places to update if the finalize semantics
ever changed (e.g. add another side effect, change the cron seed
strategy). Refactor `check_status` to delegate to `complete()`:

```rust
let _ = complete().await?;
config.chat_onboarding_completed = true;  // mirror disk into local
```

The `let _` discards `complete()`'s `ToolResult::success("ok")`
return value — `check_status` is producing its own JSON snapshot,
the caller just wants the side effect.

The `config.chat_onboarding_completed = true` after the call is an
in-memory mirror of the flip that `complete()` just performed on
disk. Without it, the JSON snapshot built later in `check_status`
would still show the pre-flip value, because the local `config` was
loaded BEFORE `complete()` ran and `complete()` operates on its own
fresh disk-loaded copy. The mirror is one line, no extra disk read,
no behavior change.

## Why not extract a `perform_finalize(&mut Config)` helper instead

Considered. Would eliminate the in-memory mirror and the redundant
disk read inside `complete()`. Ruled out because:

* `complete()` is already documented and used as a public legacy
  action; changing its signature would ripple to admin tools and
  tests that call it through the action dispatcher.
* The mirror cost is one line, zero I/O, zero correctness risk.
* The literal "call complete() inside check_status" framing matches
  the request more directly.

If a future maintainer wants to refactor further, the obvious move
is to make `complete()` take `&mut Config`, drop the redundant
`Config::load_or_init` inside `complete()`, and have both
`check_status` and the public action handler load the config once
at their top level. That's a separate refactor.

## Files

`src/openhuman/tools/impl/agent/complete_onboarding.rs` — single
function body change in `check_status`. The "flipped" arm of the
`finalize_action` match now calls `complete()` and mirrors the
flip locally instead of inlining the flip + save + cron seed.
About -10 / +5 lines.

## Tests

Library compiles. The 1 `tool_metadata` test still passes (it
pins the schema shape, not function bodies). No behavior change
for any caller.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* style(builder): cargo fmt cleanup of from_config_for_agent body (#525)

Pure indentation cleanup applied by `cargo fmt` against the
`target_def` resolution block I added in Commit 8 (`feat(web-channel):
route Tauri in-app chat to welcome/orchestrator`). The original
indentation had an awkward type annotation on the `let target_def:
Option<...> = ` line that wrapped before the `match` keyword;
rustfmt prefers the `=` on the same line as the type and the
`match` body indented under it.

Zero behavior change. Same control flow, same logic, same
diagnostics. Detected by the husky pre-push hook running
`yarn rust:check` (which runs `cargo fmt --check`).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* test(json_rpc_e2e): set chat_onboarding_completed=true in seed config (#525)

The e2e config seeded by `write_min_config` now sets
`chat_onboarding_completed = true` so `channel_web_chat` routes straight
to the orchestrator. Without this the first chat turn goes through the
new welcome agent whose tool contract is not modelled by the in-process
mock upstream, which tore down the SSE stream mid-response and caused
`json_rpc_protocol_auth_and_agent_hello` to fail with
`sse stream read failed: error decoding response body`.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Steven Enamakel <31011319+senamakel@users.noreply.github.com>
This commit is contained in:
sanil-23
2026-04-13 14:17:11 -07:00
committed by GitHub
co-authored by Claude Opus 4.6 Steven Enamakel
parent 8057f2283c
commit 7685e877ee
26 changed files with 2058 additions and 318 deletions
@@ -1,5 +1,6 @@
id = "archivist"
display_name = "Archivist"
delegate_name = "archive_session"
when_to_use = "Background librarian — extracts lessons from a completed session, updates MEMORY.md, and indexes to FTS5. Runs cheap and slow."
temperature = 0.4
max_iterations = 3
@@ -1,5 +1,6 @@
id = "code_executor"
display_name = "Code Executor"
delegate_name = "run_code"
when_to_use = "Sandboxed developer — writes, runs, and debugs code until tests pass. Use for any task that requires producing or modifying source files and exercising them with shell or test commands."
temperature = 0.4
max_iterations = 10
@@ -1,5 +1,6 @@
id = "critic"
display_name = "Critic"
delegate_name = "review_code"
when_to_use = "Adversarial reviewer — reviews diffs and code against project rules, flags vulnerabilities, regressions, and missing tests. Read-only."
temperature = 0.4
max_iterations = 5
@@ -9,13 +9,46 @@ omit_memory_context = true
omit_safety_preamble = true
omit_skills_catalog = true
# Delegation surface. The `collect_orchestrator_tools` helper reads this
# at agent-build time and synthesises one `delegate_*` tool per entry:
#
# * Bare agent ids → one `ArchetypeDelegationTool` each, using the
# target agent's `delegate_name` override (if any) or `delegate_{id}`
# as the tool name, and the target's `when_to_use` as the
# LLM-visible tool description.
#
# * `{ skills = "*" }` → one `SkillDelegationTool` per connected
# Composio toolkit, all routing to the generic `skills_agent` with
# the toolkit slug pre-filled as `skill_filter`.
#
# The orchestrator LLM sees these as first-class entries in its
# function-calling schema, so routing decisions happen at the tool-
# selection layer rather than hidden inside a mega-tool's enum param.
#
# NOTE: `subagents = [...]` MUST appear before any `[table]` section
# header — once a TOML table opens, subsequent top-level keys are
# consumed by that table, and `subagents` would get parsed as
# `tools.subagents` (which fails because `ToolScope` is an enum).
subagents = [
"researcher",
"planner",
"code_executor",
"critic",
"archivist",
{ skills = "*" },
]
[model]
hint = "reasoning"
[tools]
# Direct tools — things the orchestrator calls itself rather than
# delegating. `spawn_subagent` is retained as an advanced fallback so
# power users can still spawn arbitrary agent ids that are not listed
# in `subagents` above (e.g. workspace-override custom agents).
named = [
"spawn_subagent",
"query_memory",
"read_workspace_state",
"ask_user_clarification",
"spawn_subagent",
]
@@ -1,5 +1,6 @@
id = "planner"
display_name = "Planner"
delegate_name = "plan"
when_to_use = "Architect — break a complex task into a small DAG of subtasks with explicit acceptance criteria. Reads memory and searches the web to ground plans in real context. Read-only; produces JSON, not code."
temperature = 0.4
max_iterations = 8
@@ -1,5 +1,6 @@
id = "researcher"
display_name = "Researcher"
delegate_name = "research"
when_to_use = "Web & docs crawler — reads real documentation, compresses to dense markdown. Use for any task that requires looking up external knowledge."
temperature = 0.4
max_iterations = 8
+79 -53
View File
@@ -4,88 +4,114 @@ You are the **Welcome** agent — the first agent a new user interacts with in O
## Your workflow
### Step 1: Check setup status
You have exactly **two iterations**. There is no third.
Call `complete_onboarding` with `action: "check_status"` to get a snapshot of the user's current configuration. This tells you:
### Iteration 1: call `complete_onboarding`
- Whether they have an **API key** configured (required for inference)
- Which **messaging channels** are connected (Telegram, Discord, Slack, etc.)
- Which **integrations** are active (Composio, browser, web search, etc.)
- **Memory** backend and auto-save settings
- **Local AI** model status
- Any **delegate agents** configured
Emit a single tool call and nothing else:
### Step 2: Greet and guide
```
complete_onboarding({"action": "check_status"})
```
Based on the status report, write a message that:
No greeting, no thinking out loud, no preamble. Just the tool call. The user's message is irrelevant to this rule — they might say "hi", "hello", a single emoji, or nothing meaningful — your first iteration is always the same one tool call.
1. **Acknowledges what they've done.** If they've connected channels or integrations, call them out by name. Show you're paying attention.
2. **Points out what's missing (if anything).** Be helpful, not nagging. If they don't have an API key, that's critical — mention it clearly. If they haven't connected any channels, gently suggest it. If everything looks good, celebrate that.
3. **Explains what you can do.** Based on their actual setup, tease the capabilities they've unlocked. Connected Gmail via Composio? Mention you can help manage their inbox. Have Telegram set up? You'll be there when they message. Keep it specific to *their* setup.
4. **Sets the tone.** You're the first personality they meet. Be warm, witty, and confident — like a sharp colleague who already knows the lay of the land. Not a corporate onboarding wizard.
The tool returns a **JSON snapshot** of the user's config AND, when the user is authenticated, automatically flips `chat_onboarding_completed = true` + seeds proactive cron jobs as a side effect. You don't need to call any other tool. The auto-finalize is built into `check_status` itself. See the `complete_onboarding` tool description for the full JSON schema.
### Step 3: Complete onboarding (when appropriate)
### Iteration 2: write the welcome message based on the JSON
Once you've delivered your welcome and the user seems oriented:
Read the JSON snapshot from iteration 1 and use it to draft a personalised welcome message in natural prose. **Do not** quote, paraphrase, or repeat the JSON back to the user — translate the field values into a warm, observant message about their specific setup.
- If the essentials are in place (at minimum an API key), call `complete_onboarding` with `action: "complete"` to finalize onboarding. This sets up recurring proactive agents like the morning briefing.
- If critical setup is missing (no API key), **do not** complete onboarding. Instead, explain what they need to do and let them know you'll be here when they're ready.
Use the `finalize_action` field to decide the message's framing:
### Step 4: Subscription upsell and referral
- **`"flipped"`** — user is authenticated AND this is the first welcome. Write the full welcome message: acknowledge their setup, point out gaps, tease capabilities, present subscription/referral, hand off. The flag has already been flipped server-side; their next chat turn will route to the orchestrator automatically.
- **`"already_complete"`** — user is authenticated AND already finished onboarding before. Same as above but acknowledge they've been here before ("good to see you back"). Still hand off. Their next chat turn already routes to the orchestrator.
- **`"skipped_no_auth"`** — user is not authenticated. Do NOT write a celebratory welcome. Instead, briefly explain that they need to log in via the desktop app first, point them at where to do that, and let them know you'll be here when they're ready. The flag was NOT flipped; the next chat turn will re-run welcome, so this is a friendly retry path, not a hard error.
After completing onboarding, **always** present the subscription opportunity. This is important — without a plan, the user's experience is limited. Here's what you need to convey:
### Message structure (when finalize_action is "flipped" or "already_complete")
**Free tier:**
- Every new user gets **$1 of free credits** to explore OpenHuman — enough to try things out, but it runs out fast with real usage.
Weave these into one cohesive message — don't make it feel like separate sections:
**Subscription plans:**
- A subscription unlocks **better pricing on all credits** — the same dollar goes further.
- Subscribers get **priority access** to new features and models.
- Frame it as: "You've got $1 to play with, but if you're planning to actually use this day-to-day, a subscription makes your credits stretch a lot further."
1. **Acknowledge what they've done.** Use the `channels_connected`, `integrations`, and `delegate_agents` fields. Call out specific connections by name. "I see you've got Discord hooked up and Gmail connected via Composio" is much better than generic praise.
2. **Point out what's missing**, with assertiveness scaled to the gap:
- **No messaging channels** (`channels_connected: []`) → note that without Telegram/Discord/Slack/etc. you can only reach them while the Tauri app is open. Suggest connecting one for proactive briefings/alerts.
- **No Composio integrations** (`integrations.composio: false`) → softer nudge if the rest is fine; stronger if the user has *nothing* connected (see "bare install" below).
- **Bare install** (no channels AND no Composio AND no web search AND no browser) → see the bare-install handling below. This is the most important case to handle well.
3. **Tease capabilities they've unlocked or could unlock.** Use the integration capability reference below. For things they have, describe what you can do. For 2-3 things they don't, paint a concrete picture with example prompts.
4. **Subscription + referral** (one short paragraph each). $1 of free credits, subscription stretches them further, refer a friend → both get $5.
5. **Close naturally.** End the message with something inviting like "anything you'd like to try first?" or "what should we dig into?" — a normal conversational close that lets the user pick up the next turn. Do NOT mention handing off, transferring, or any change in who they're talking to. The user does not know there are multiple agents under the hood; from their perspective they're talking to OpenHuman as one entity, and the next message they send will just continue the conversation.
**Referral program:**
- If the user refers a friend who subscribes, **both the user and the friend get $5 of extra credits**.
- This is a genuine win-win — mention it naturally, not as a hard sell. Something like: "And if you know someone who'd get value from this, the referral program gives you both $5 in credits when they subscribe."
Aim for **200-350 words** for a typical user, **250-400 words** for a bare-install user where you also need to pitch 2-3 specific integrations with concrete example prompts.
**Tone for the upsell:**
- Be matter-of-fact, not salesy. You're informing them of how the economics work, not pushing a quota.
- Lead with value ("your credits go further") not fear ("you'll run out").
- Keep it brief — 2-3 sentences max for the subscription, 1 sentence for the referral.
### Handling a bare install (no channels, no integrations, nothing beyond auth)
### Step 5: Hand off to main workspace
When `channels_connected` is empty AND `integrations.composio` is false AND `integrations.web_search` is false AND `integrations.browser` is false, the user has a fully functional reasoning + coding assistant with memory but zero reach into the real world. Don't gloss over this.
After the welcome and upsell, close out by letting the user know that from here on out, they'll be talking to the main assistant — the orchestrator — which has the full range of tools and capabilities at its disposal.
1. **State what they DO have, honestly.** Sandboxed reasoning + coding assistant with memory. That's real — it can think through problems, write and run code in a sandbox, review diffs, plan work, and remember past conversations. Some users genuinely want only that, and if so it's a perfectly valid way to use OpenHuman.
2. **State what they're MISSING.** Without integrations the assistant can't send emails, read inboxes, manage GitHub, access Notion, browse the web, or take any action in an external service. Every "what emails came in overnight"-style question will hit a wall.
3. **Pitch 2-3 specific integrations** from the capability reference with concrete example prompts. Don't list everything; pick the most likely to be useful (default to Gmail + GitHub + one of {Calendar, Notion}).
4. **Tell them where to go.** Settings → Integrations for Composio, Settings → Channels for messaging platforms.
5. **Leave the door open.** "If you just want the coding helper, that's fine — the main assistant can still do a lot without integrations. But the experience gets much better once you plug at least one external service in."
Say something like: "From here, you're in the hands of the full OpenHuman assistant. Just start a new conversation and ask it anything — it knows how to delegate to specialists, run tools, search the web, manage your integrations, and more."
### Handling missing authentication (`finalize_action: "skipped_no_auth"`)
This is your sign-off. The welcome agent's job is done.
Skip the celebratory welcome entirely. Write something brief and helpful:
## Gathering context
> "Welcome to OpenHuman. Quick heads up: you need to log in via the desktop app before I can do anything for you. Head to the login screen, finish the OAuth handshake, and the next time you chat with me I'll have everything I need to get you set up properly. See you in a minute."
- Start by calling `complete_onboarding` with `action: "check_status"` — this is your primary information source.
- Use the **memory context** injected above by the system prompt builder for any user profile data, preferences, or early choices.
- If you need more detail, call `memory_recall` with targeted queries like "user profile", "connected integrations", "onboarding".
- Work with whatever you find. A sparse setup is fine — be honest about what's there and what's not.
Do not pitch integrations, do not present subscription info, do not hand off. The user needs to fix one specific thing first; everything else can wait for the next welcome turn.
## Integration capability reference
Use this as your menu when telling the user what an integration would unlock. Pick the 2-3 most likely to matter; don't list everything. Each entry is meant to be a one- or two-line "if X, then Y" tease with a concrete example prompt.
**Composio — external services (Settings → Integrations → Composio):**
- **Gmail** → read, search, draft, send, manage labels. Example: *"Summarise the most important emails that came in overnight and flag anything that needs a reply today."*
- **Google Calendar** → read agenda, find free slots, create events. Example: *"What's on my calendar tomorrow, and do I have a 30-minute gap before 2pm?"*
- **GitHub** → browse repos, read and manage issues and PRs, comment, review. Example: *"List open issues on my main project tagged 'bug' and summarise which ones look newest or most urgent."*
- **Notion** → read and write pages, query databases, manage blocks. Example: *"Pull up my 'Ideas' Notion database and show me the three newest entries."*
- **Slack / Discord** → send messages, read channel history, react. Example: *"Post a status update to my team's Slack #eng-standup channel."*
- **Linear / Jira** → manage tasks and projects. Example: *"What Linear tickets are assigned to me and in progress?"*
There are 1000+ Composio toolkits total; the ones above are the most common. Mention whichever feels right for the user's profile if you have context, otherwise default to Gmail + GitHub + one of {Calendar, Notion}.
**Messaging platforms — how the user talks to you (Settings → Channels):**
- **Telegram** → ping the user on their phone for proactive messages, receive chat commands from anywhere. Fastest mobile setup.
- **Discord** → useful if the user lives in Discord servers already.
- **Slack** → useful for work contexts where the user is already in a Slack workspace all day.
- **iMessage / WhatsApp / Signal** → platform-native chat for users who prefer those.
- **Web (in-app Tauri chat)** → always available as a fallback, no setup needed, but only works while the app window is open.
**Other capabilities (Settings → Integrations):**
- **Web search** — grounds research and planning tasks in real-time web results. Without it, researcher/planner subagents fall back to memory only.
- **Browser automation** — programmatic web navigation. Useful for scraping and form automation.
- **HTTP requests** — call arbitrary REST APIs beyond what Composio covers.
- **Local AI** — runs inference on the user's own machine for privacy-sensitive work.
## Tone guidelines
- **Warm but direct.** You're helpful and personable, not sycophantic. Think helpful concierge, not desperate chatbot.
- **Warm but direct.** Helpful and personable, not sycophantic. Helpful concierge, not desperate chatbot.
- **Confident.** You know the system well. Own that knowledge with clarity, not arrogance.
- **Observant.** Reference specific things from their setup. "I see you've got Discord hooked up" beats generic advice.
- **Concise.** Keep your messages focused. The welcome + upsell + handoff should flow naturally as one message, around 200-350 words total. Don't make it feel like three separate sections — weave it together.
- **Length matches the work.** 200-350 words for a typical user, 250-400 for a bare-install user. A 1-2 sentence greeting is a failure, not a "concise" success — the chat layer downstream will not give you a second chance to deliver the welcome experience.
## What NOT to do
- Don't list every possible feature like a product tour. Focus on what's relevant to *their* setup.
- Don't write any prose in iteration 1. The first iteration is a tool call and only a tool call.
- Don't quote, paraphrase, or summarise the JSON snapshot back to the user. The JSON is your fact source; your output is natural prose.
- Don't reply with a 1-line greeting like "Hey! What's up?". The user's input length is irrelevant to your output length — even "hi" gets the full welcome experience.
- Don't list every possible feature like a product tour, except in the bare-install case where picking 2-3 specific integrations with example prompts is the whole point.
- Don't be sycophantic ("I'm SO excited to help you!"). Be cool.
- Don't make promises about capabilities they haven't enabled.
- Don't reference technical internals (cron jobs, agent IDs, config TOML paths). Speak in user terms.
- Don't use emojis unless the user's profile suggests they'd appreciate it.
- Don't skip the `check_status` call — always ground your advice in actual config state.
- Don't complete onboarding if the user is missing critical setup (no API key).
- Don't be pushy about the subscription. Inform, don't pressure. One mention is enough.
- Don't skip the subscription and referral information — every user should hear about it.
- Don't forget to hand off — the user needs to know the welcome agent is done and the main assistant is ready.
- Don't promise capabilities they haven't enabled. Describing what would unlock if they connected X is fine; claiming "I can read your email" when Gmail isn't connected is not.
- Don't reference technical internals (cron jobs, agent IDs, config flags, the JSON schema, the `finalize_action` field). Speak in user terms.
- Don't use emojis unless the user's profile suggests they'd appreciate them.
- Don't pitch the subscription if `finalize_action` is `"skipped_no_auth"` — they need to fix login first; sales talk is wrong for that case.
- Don't be pushy about the subscription anywhere. Inform, don't pressure.
- **Don't reveal that the conversation is being routed to a different agent.** From the user's perspective they are talking to "OpenHuman" — one assistant, one conversation. Do NOT say "I'll hand you off to the main assistant", "the orchestrator will take over", "you're now in the hands of the full assistant", "from here on out you'll be talking to a different agent", or any variation that exposes the welcome → orchestrator handoff. The handoff happens transparently in the routing layer; the user just sends another message and the conversation continues. Phrases like "what should we dig into?" or "anything you'd like to try first?" are correct conversational closes — they invite the next turn without leaking the architecture.
- Don't gloss over a bare install. If the user has nothing beyond auth, explain what they'd gain with concrete pitches — otherwise they'll leave the welcome and never come back to Settings.
## Output
+40
View File
@@ -13,6 +13,7 @@
//! See [`crate::openhuman::channels::runtime::dispatch`] for the primary
//! caller.
use std::collections::HashSet;
use std::sync::Arc;
use tokio::sync::mpsc;
@@ -84,6 +85,33 @@ pub struct AgentTurnRequest {
/// chunks here so channel providers can update "draft" messages in
/// real time. `None` disables streaming for this turn.
pub on_delta: Option<mpsc::Sender<String>>,
// ── Per-agent scoping (issues #525 / #526) ────────────────────────
/// Identifier of the agent definition this turn represents (e.g.
/// `"orchestrator"`, `"welcome"`). Used for structured tracing and
/// downstream bookkeeping; the actual filtering is driven by
/// [`Self::visible_tool_names`] and [`Self::extra_tools`] below.
/// `None` preserves the legacy "generic unfiltered turn" behaviour.
pub target_agent_id: Option<String>,
/// Whitelist of tool names visible to the LLM this turn. When
/// `Some(set)`, the bus handler filters both the function-calling
/// schema and the tool-execution lookup to names in the set.
/// Pre-built on the dispatch side from the target agent's
/// definition (its `[tools] named` list unioned with the names of
/// any per-turn synthesised delegation tools). `None` means no
/// filter — every tool in `tools_registry` plus `extra_tools` is
/// visible.
pub visible_tool_names: Option<HashSet<String>>,
/// Per-turn synthesised tools to splice alongside `tools_registry`.
/// The dispatch path uses this to carry `ArchetypeDelegationTool` /
/// `SkillDelegationTool` instances built fresh each turn from the
/// active agent's `subagents` field and the current Composio
/// integrations — tools that don't exist in the global startup
/// registry because they depend on per-user runtime state.
/// Empty vec for agents that don't delegate.
pub extra_tools: Vec<Box<dyn Tool>>,
}
/// Final response from an agentic turn.
@@ -114,14 +142,21 @@ pub fn register_agent_handlers() {
multimodal,
max_tool_iterations,
on_delta,
target_agent_id,
visible_tool_names,
extra_tools,
} = req;
tracing::debug!(
channel = %channel_name,
target_agent = target_agent_id.as_deref().unwrap_or("<unset>"),
provider = %provider_name,
model = %model,
history_len = history.len(),
tool_count = tools_registry.len(),
extra_tool_count = extra_tools.len(),
visible_tool_count = visible_tool_names.as_ref().map(|s| s.len()).unwrap_or(0),
filter_active = visible_tool_names.is_some(),
streaming = on_delta.is_some(),
"[agent::bus] dispatching {AGENT_RUN_TURN_METHOD}"
);
@@ -143,6 +178,8 @@ pub fn register_agent_handlers() {
&multimodal,
max_tool_iterations,
on_delta,
visible_tool_names.as_ref(),
&extra_tools,
)
.await
.map_err(|e| e.to_string())?;
@@ -286,6 +323,9 @@ mod tests {
multimodal: MultimodalConfig::default(),
max_tool_iterations: 1,
on_delta: None,
target_agent_id: None,
visible_tool_names: None,
extra_tools: Vec::new(),
}
}
@@ -65,6 +65,8 @@ pub fn fork_definition() -> AgentDefinition {
sandbox_mode: SandboxMode::None,
background: false,
uses_fork_context: true,
subagents: vec![],
delegate_name: None,
source: DefinitionSource::Builtin,
}
}
+177
View File
@@ -115,12 +115,90 @@ pub struct AgentDefinition {
#[serde(default, skip_serializing_if = "is_false")]
pub uses_fork_context: bool,
// ── delegation surface ─────────────────────────────────────────────
/// Subagents this agent is allowed to spawn via synthesised
/// `delegate_*` tools. Each entry expands at agent-build time into
/// one tool the LLM can call in its function-calling schema:
///
/// * [`SubagentEntry::AgentId`] — one [`ArchetypeDelegationTool`]
/// whose name defaults to `delegate_{agent_id}` (or the target
/// agent's `delegate_name` override) and whose description is the
/// target agent's [`AgentDefinition::when_to_use`].
///
/// * [`SubagentEntry::Skills`] — one [`SkillDelegationTool`] per
/// connected Composio toolkit, each named `delegate_{toolkit}`,
/// all routing to the generic `skills_agent` with an appropriate
/// `skill_filter` pre-populated.
///
/// `subagents` is intentionally separate from [`AgentDefinition::tools`]
/// so that reading a TOML makes the distinction obvious: `tools` is
/// "what I execute directly", `subagents` is "what I can delegate to".
///
/// [`ArchetypeDelegationTool`]: crate::openhuman::tools::impl::agent::ArchetypeDelegationTool
/// [`SkillDelegationTool`]: crate::openhuman::tools::impl::agent::SkillDelegationTool
#[serde(default)]
pub subagents: Vec<SubagentEntry>,
/// Optional override for the tool name this agent is exposed as when
/// another agent lists it in its [`subagents`]. Defaults to
/// `delegate_{id}` when absent. Kept separate from `display_name` so
/// the UI display and the LLM tool name can diverge (e.g.
/// `display_name = "Researcher"`, `delegate_name = "research"`).
#[serde(default)]
pub delegate_name: Option<String>,
// ── source bookkeeping ──────────────────────────────────────────────
/// Tracks where the definition was loaded from (Builtin vs. File).
#[serde(skip)]
pub source: DefinitionSource,
}
// ─────────────────────────────────────────────────────────────────────────────
// Subagent delegation entries
// ─────────────────────────────────────────────────────────────────────────────
/// One entry in [`AgentDefinition::subagents`]. Parses from TOML as either
/// a bare string (agent id) or an inline table (`{ skills = "*" }`) thanks
/// to `#[serde(untagged)]`.
///
/// # TOML shapes
///
/// ```toml
/// subagents = [
/// "researcher", # AgentId("researcher")
/// "code_executor", # AgentId("code_executor")
/// { skills = "*" }, # Skills { pattern: "*" }
/// ]
/// ```
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(untagged)]
pub enum SubagentEntry {
/// Delegate to a specific built-in or custom agent by id.
AgentId(String),
/// Expand at build time to one `delegate_{toolkit}` tool per
/// connected Composio toolkit, each routing to the generic
/// `skills_agent` with `skill_filter` pre-set.
Skills(SkillsWildcard),
}
/// The `{ skills = "*" }` inline table in a `subagents` list.
///
/// Today only `"*"` is meaningful (expand to every connected toolkit).
/// Future: a `Vec<String>` variant to restrict expansion to specific
/// toolkit slugs (e.g. `{ skills = ["gmail", "notion"] }`).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SkillsWildcard {
/// Glob / wildcard pattern. Only `"*"` is currently supported.
pub skills: String,
}
impl SkillsWildcard {
/// True when this wildcard should expand to every connected toolkit.
pub fn matches_all(&self) -> bool {
self.skills == "*"
}
}
fn is_false(b: &bool) -> bool {
!b
}
@@ -411,6 +489,8 @@ mod tests {
sandbox_mode: SandboxMode::None,
background: false,
uses_fork_context: false,
subagents: vec![],
delegate_name: None,
source: DefinitionSource::Builtin,
}
}
@@ -466,4 +546,101 @@ mod tests {
def2.display_name = Some("Beta Specialist".into());
assert_eq!(def2.display_name(), "Beta Specialist");
}
// ── subagents parsing ─────────────────────────────────────────────
/// Parses a minimal TOML document with a `subagents` list containing
/// both a bare agent-id string and an inline `{ skills = "*" }` table.
/// Ensures the `#[serde(untagged)]` enum routes each shape to the
/// correct variant without the TOML needing explicit tags.
///
/// NOTE: `subagents = [...]` must appear **before** the `[tools]`
/// table header in the TOML — once you open a TOML table section,
/// every subsequent top-level key is consumed by that table, so
/// `subagents` placed after `[tools]` would be parsed as
/// `tools.subagents` and fail because `ToolScope` is an enum, not
/// a struct with a `subagents` field.
#[test]
fn subagents_parses_mixed_string_and_table_entries() {
let toml_src = r#"
id = "orchestrator"
when_to_use = "Routes work to the right specialist"
temperature = 0.4
max_iterations = 15
subagents = [
"researcher",
"code_executor",
{ skills = "*" },
]
[tools]
named = ["query_memory"]
"#;
let def: AgentDefinition = toml::from_str(toml_src).expect("toml parse");
assert_eq!(def.subagents.len(), 3);
assert_eq!(
def.subagents[0],
SubagentEntry::AgentId("researcher".into())
);
assert_eq!(
def.subagents[1],
SubagentEntry::AgentId("code_executor".into())
);
assert_eq!(
def.subagents[2],
SubagentEntry::Skills(SkillsWildcard { skills: "*".into() })
);
}
/// `subagents` is optional — omitting it should yield an empty Vec
/// rather than a deserialization error. Most non-delegating agents
/// (welcome, archivist, code_executor, etc.) will not list any.
#[test]
fn subagents_defaults_to_empty_when_omitted() {
let toml_src = r#"
id = "welcome"
when_to_use = "First agent a new user speaks to"
temperature = 0.7
max_iterations = 6
[tools]
named = ["complete_onboarding", "memory_recall"]
"#;
let def: AgentDefinition = toml::from_str(toml_src).expect("toml parse");
assert!(def.subagents.is_empty());
assert!(def.delegate_name.is_none());
}
/// The `delegate_name` field lets an agent expose itself under a
/// shorter / more natural tool name than `delegate_{id}`. For example
/// the `researcher` agent is exposed as `research` in the
/// orchestrator's tool list.
#[test]
fn delegate_name_overrides_default() {
let toml_src = r#"
id = "researcher"
when_to_use = "Web & docs crawler"
delegate_name = "research"
temperature = 0.4
max_iterations = 8
"#;
let def: AgentDefinition = toml::from_str(toml_src).expect("toml parse");
assert_eq!(def.delegate_name.as_deref(), Some("research"));
}
/// `SkillsWildcard::matches_all` is the predicate the tool builder
/// checks before expanding a wildcard into per-toolkit tools. Only
/// the literal `"*"` should be accepted today — any other pattern
/// (reserved for future specific-toolkit lists) must not match.
#[test]
fn skills_wildcard_only_star_matches_all() {
let star = SkillsWildcard { skills: "*".into() };
assert!(star.matches_all());
let specific = SkillsWildcard {
skills: "gmail".into(),
};
assert!(!specific.matches_all());
}
}
+292 -20
View File
@@ -303,18 +303,132 @@ impl AgentBuilder {
impl Agent {
/// Constructs an `Agent` instance from a global system configuration.
///
/// This is the primary factory method for initializing an agent with all
/// standard system integrations (memory, tools, skills, providers, learning)
/// configured according to the user's `config.toml`.
/// Thin wrapper around [`Agent::from_config_for_agent`] that always
/// targets the orchestrator definition. This preserves the legacy
/// "main agent = orchestrator" behaviour for CLI / REPL / any caller
/// that does not participate in the #525 onboarding-routing flow.
///
/// It performs the heavy lifting of:
/// Callers that need to select a different agent at session-build
/// time (for example the Tauri web chat path, which routes to the
/// welcome agent pre-onboarding) should call
/// [`Agent::from_config_for_agent`] directly.
pub fn from_config(config: &Config) -> Result<Self> {
Self::from_config_for_agent(config, "orchestrator")
}
/// Constructs an `Agent` instance scoped to a specific agent
/// definition loaded from the global [`AgentDefinitionRegistry`].
///
/// `agent_id` is looked up in the registry; the returned agent
/// inherits that definition's `ToolScope`, `system_prompt`,
/// `temperature`, `max_iterations`, and `omit_*` flags. Unknown
/// agent ids produce a registry-lookup error rather than silently
/// falling back to the orchestrator.
///
/// Shared infrastructure between agent ids is identical:
/// 1. Initializing the host runtime (native or docker).
/// 2. Setting up security policies.
/// 3. Initializing memory and embedding services.
/// 4. Registering all built-in and orchestrator tools.
/// 5. Configuring the routed AI provider.
/// 6. Setting up the learning system and post-turn hooks.
pub fn from_config(config: &Config) -> Result<Self> {
///
/// What differs per agent id:
/// * `visible_tool_names` is the agent's `ToolScope::Named` list
/// (unioned with the names of synthesised delegation tools when
/// the agent declares `subagents = [...]`). `ToolScope::Wildcard`
/// yields an empty filter, matching the legacy unfiltered path.
/// * `prompt_builder` uses [`SystemPromptBuilder::for_subagent`]
/// with the agent's inline/file prompt body and `omit_*` flags,
/// so each agent renders its own persona rather than the default
/// orchestrator workspace-files identity dump.
/// * `temperature` comes from the agent's TOML (falls back to
/// `config.default_temperature` for the orchestrator to preserve
/// legacy behaviour).
///
/// The welcome agent uses this entry point when routed from the
/// Tauri web channel (see `channels::providers::web::build_session_agent`).
pub fn from_config_for_agent(config: &Config, agent_id: &str) -> Result<Self> {
use crate::openhuman::agent::harness::definition::{AgentDefinitionRegistry, ToolScope};
// Look up the target definition up front so we can fail fast
// with a clear error instead of building half an agent and then
// discovering the id is unknown. The registry is a singleton
// initialised at startup; if it's not yet populated we
// conservatively fall back to the legacy "orchestrator-shaped"
// build by proceeding without a definition override.
let target_def: Option<crate::openhuman::agent::harness::definition::AgentDefinition> =
match AgentDefinitionRegistry::global() {
Some(reg) => match reg.get(agent_id) {
Some(def) => Some(def.clone()),
None if agent_id == "orchestrator" => {
// Orchestrator is allowed to be missing from the
// registry (legacy path, tests, pre-startup) —
// fall back to default behaviour.
log::debug!(
"[agent::builder] orchestrator definition not in registry — \
using legacy default prompt + filter"
);
None
}
None => {
return Err(anyhow::anyhow!(
"agent definition '{}' not found in registry",
agent_id
));
}
},
None => {
if agent_id != "orchestrator" {
return Err(anyhow::anyhow!(
"AgentDefinitionRegistry is not initialised — cannot \
resolve agent '{}'. Call AgentDefinitionRegistry::init_global \
at startup.",
agent_id
));
}
log::debug!(
"[agent::builder] registry not initialised, orchestrator requested — \
using legacy default prompt + filter"
);
None
}
};
log::info!(
"[agent::builder] building session agent id={} \
(scope={}, omit_identity={}, temperature={:.2})",
agent_id,
target_def
.as_ref()
.map(|d| match &d.tools {
ToolScope::Named(names) => format!("named({})", names.len()),
ToolScope::Wildcard => "wildcard".to_string(),
})
.unwrap_or_else(|| "legacy".to_string()),
target_def
.as_ref()
.map(|d| d.omit_identity)
.unwrap_or(false),
target_def
.as_ref()
.map(|d| d.temperature)
.unwrap_or(config.default_temperature)
);
Self::build_session_agent_inner(config, agent_id, target_def.as_ref())
}
/// Internal constructor that consumes the optionally-resolved agent
/// definition. Split out from [`Agent::from_config_for_agent`] so
/// the lookup + logging live in one place and the heavy-lifting
/// body stays readable.
fn build_session_agent_inner(
config: &Config,
agent_id: &str,
target_def: Option<&crate::openhuman::agent::harness::definition::AgentDefinition>,
) -> Result<Self> {
use crate::openhuman::agent::harness::definition::{PromptSource, ToolScope};
let runtime: Arc<dyn host_runtime::RuntimeAdapter> =
Arc::from(host_runtime::create_runtime(&config.runtime)?);
let security = Arc::new(SecurityPolicy::from_config(
@@ -385,8 +499,68 @@ impl Agent {
let dispatcher_choice = config.agent.tool_dispatcher.clone();
let supports_native = provider.supports_native_tools();
// Build prompt builder, optionally with learning sections
let mut prompt_builder = SystemPromptBuilder::with_defaults();
// Build prompt builder — either the default "orchestrator /
// main agent" layout that bootstraps from workspace identity
// files, OR a narrow per-agent builder that injects the target
// definition's `prompt.md` body and respects its `omit_*` flags.
//
// The narrow path is selected whenever we resolved a
// non-orchestrator definition from the registry. Welcome agent
// is the first real consumer: its TOML sets
// `omit_identity = true`, `omit_memory_context = false`,
// `omit_safety_preamble = true`, `omit_skills_catalog = true`,
// so the rendered prompt becomes:
//
// (welcome persona body)
// ── Memory context (user profile, learned observations)
// ── Tools (2 entries: complete_onboarding + memory_recall)
// ── Workspace directory
//
// The orchestrator continues to use `with_defaults` so its
// prompt stays byte-identical to the legacy CLI/REPL behaviour
// except for the tool-scope tightening we already landed in
// earlier commits.
let mut prompt_builder = match target_def {
Some(def) if agent_id != "orchestrator" => {
// Resolve the prompt body. For built-in agents,
// `system_prompt` is `PromptSource::Inline(...)` populated
// at crate-build time from the sibling `prompt.md` via
// `include_str!` in `agents/mod.rs`. File-based prompts
// (custom workspace overrides) read from disk.
let body = match &def.system_prompt {
PromptSource::Inline(text) => text.clone(),
PromptSource::File { path } => {
let workspace_path = config
.workspace_dir
.join("agent")
.join("prompts")
.join(path);
if workspace_path.is_file() {
std::fs::read_to_string(&workspace_path).unwrap_or_else(|e| {
log::warn!(
"[agent::builder] failed to read prompt {}: {e} — using empty body",
workspace_path.display()
);
String::new()
})
} else {
log::warn!(
"[agent::builder] prompt file {} not found — using empty body",
path
);
String::new()
}
}
};
SystemPromptBuilder::for_subagent(
body,
def.omit_identity,
def.omit_safety_preamble,
def.omit_skills_catalog,
)
}
_ => SystemPromptBuilder::with_defaults(),
};
if config.learning.enabled {
prompt_builder = prompt_builder
.add_section(Box::new(
@@ -453,21 +627,101 @@ impl Agent {
}
}
// Generate the orchestrator's tool set: one tool per skill +
// one tool per archetype (research, run_code, etc.) + spawn_subagent
// as a fallback. These are the only tools the LLM sees in its
// function-calling schema. Sub-agents still access the full `tools`
// registry via ParentExecutionContext.
let orchestrator_tools = tools::orchestrator_tools::collect_orchestrator_tools();
let visible: std::collections::HashSet<String> = orchestrator_tools
.iter()
.map(|t| t.name().to_string())
.collect();
// De-duplicate: spawn_subagent is already in the base registry.
// Resolve the per-agent delegation tool set and visible-tool
// whitelist from the target definition (when we have one) or
// fall back to the orchestrator's synthesis path.
//
// For an agent with `subagents = [...]` in its TOML (today:
// orchestrator), `collect_orchestrator_tools` synthesises one
// `ArchetypeDelegationTool` per named sub-agent plus one
// `SkillDelegationTool` per connected Composio toolkit.
//
// For an agent without `subagents` (today: welcome, critic,
// archivist, etc.), no delegation tools are synthesised — the
// LLM only sees the agent's own `ToolScope::Named` entries
// from the global registry, narrowed by the visible-tool
// filter.
//
// This builder is synchronous and sits on the CLI / REPL /
// Tauri-web code path. It does not have access to the async
// Composio fetcher, so we pass an empty slice of connected
// integrations here — the skill-wildcard expansion therefore
// produces zero delegation tools. That is correct behaviour:
// callers that need live integration expansion go through the
// bus-based `channels::runtime::dispatch` path instead.
let (delegation_tools, filter_from_scope): (
Vec<Box<dyn Tool>>,
Option<std::collections::HashSet<String>>,
) = match (
target_def,
crate::openhuman::agent::harness::definition::AgentDefinitionRegistry::global(),
) {
(Some(def), Some(reg)) => {
let synthed = tools::orchestrator_tools::collect_orchestrator_tools(def, reg, &[]);
let filter: Option<std::collections::HashSet<String>> = match &def.tools {
ToolScope::Named(names) => {
let mut set: std::collections::HashSet<String> =
names.iter().cloned().collect();
for t in &synthed {
set.insert(t.name().to_string());
}
Some(set)
}
ToolScope::Wildcard => None,
};
(synthed, filter)
}
(None, Some(reg)) => {
// Legacy orchestrator fallback (no target definition).
// Keeps the pre-refactor behaviour byte-identical for
// callers that invoke the old `from_config` on a
// pre-startup or test registry state.
let synthed = match reg.get("orchestrator") {
Some(orch_def) => {
tools::orchestrator_tools::collect_orchestrator_tools(orch_def, reg, &[])
}
None => {
log::debug!(
"[agent::builder] orchestrator definition not in registry — \
skipping delegation tool synthesis"
);
Vec::new()
}
};
(synthed, None)
}
(_, None) => {
log::debug!(
"[agent::builder] AgentDefinitionRegistry not initialised — \
skipping delegation tool synthesis"
);
(Vec::new(), None)
}
};
// The final visible-tool whitelist is the union of whatever the
// definition scope produced (for named scopes) and every tool
// we just synthesised as a delegation wrapper. When the
// definition is `ToolScope::Wildcard` (legacy default, no
// filter), we still populate `visible` from the delegation
// tools alone so the existing `Agent::visible_tool_names`
// contract (empty == no filter) stays intact: an empty set
// means "no filter" for both legacy callers and the new
// agent-scoped path.
let visible: std::collections::HashSet<String> = match filter_from_scope {
Some(set) => set,
None => delegation_tools
.iter()
.map(|t| t.name().to_string())
.collect(),
};
// De-duplicate: some synthesised tool names may collide with
// already-registered tools (unlikely for `delegate_*` names but
// cheap to guard against).
let existing_names: std::collections::HashSet<String> =
tools.iter().map(|t| t.name().to_string()).collect();
tools.extend(
orchestrator_tools
delegation_tools
.into_iter()
.filter(|t| !existing_names.contains(t.name())),
);
@@ -492,6 +746,24 @@ impl Agent {
pformat_registry.len()
);
// Temperature override: when we have a target definition, use
// its declared temperature from the TOML (welcome is 0.7,
// orchestrator is 0.4, etc). Fall back to
// `config.default_temperature` for the legacy "no definition"
// path so existing callers keep getting their configured value.
let effective_temperature = target_def
.map(|def| def.temperature)
.unwrap_or(config.default_temperature);
// `agent_id` is not stamped onto the returned Agent here — the
// `event_channel` field on `Agent` is for transport identity
// (which channel the session belongs to: "web_channel", "cli",
// etc.), not the agent-definition id. Callers that want to
// record which definition they asked for should log it at
// call-site. The `[agent::builder]` info trace at the top of
// `from_config_for_agent` already captures this.
let _ = agent_id; // silence unused-warning if all code paths above move
Agent::builder()
.provider(provider)
.tools(tools)
@@ -506,7 +778,7 @@ impl Agent {
.config(config.agent.clone())
.context_config(config.context.clone())
.model_name(model_name)
.temperature(config.default_temperature)
.temperature(effective_temperature)
.workspace_dir(config.workspace_dir.clone())
.skills(crate::openhuman::skills::load_skills(&config.workspace_dir))
.auto_save(config.memory.auto_save)
@@ -791,6 +791,8 @@ mod tests {
sandbox_mode: super::super::definition::SandboxMode::None,
background: false,
uses_fork_context: false,
subagents: vec![],
delegate_name: None,
source: super::super::definition::DefinitionSource::Builtin,
}
}
+6
View File
@@ -124,6 +124,8 @@ async fn run_tool_call_loop_returns_structured_error_for_non_vision_provider() {
&crate::openhuman::config::MultimodalConfig::default(),
3,
None,
None,
&[],
)
.await
.expect_err("provider without vision support should fail");
@@ -165,6 +167,8 @@ async fn run_tool_call_loop_rejects_oversized_image_payload() {
&multimodal,
3,
None,
None,
&[],
)
.await
.expect_err("oversized payload must fail");
@@ -200,6 +204,8 @@ async fn run_tool_call_loop_accepts_valid_multimodal_request_flow() {
&crate::openhuman::config::MultimodalConfig::default(),
3,
None,
None,
&[],
)
.await
.expect("valid multimodal payload should pass");
+88 -9
View File
@@ -4,13 +4,12 @@ use crate::openhuman::providers::{ChatMessage, ChatRequest, Provider, ProviderCa
use crate::openhuman::tools::traits::ToolScope;
use crate::openhuman::tools::Tool;
use anyhow::Result;
use std::collections::HashSet;
use std::fmt::Write as _;
use std::io::Write as _;
use super::credentials::scrub_credentials;
use super::parse::{
build_native_assistant_history, find_tool, parse_structured_tool_calls, parse_tool_calls,
};
use super::parse::{build_native_assistant_history, parse_structured_tool_calls, parse_tool_calls};
use crate::openhuman::context::guard::{ContextCheckResult, ContextGuard};
/// Minimum characters per chunk when relaying LLM text to a streaming draft.
@@ -23,6 +22,11 @@ pub(crate) const DEFAULT_MAX_TOOL_ITERATIONS: usize = 10;
/// Execute a single turn of the agent loop: send messages, parse tool calls,
/// execute tools, and loop until the LLM produces a final text response.
/// When `silent` is true, suppresses stdout (for channel use).
///
/// This is a thin wrapper around [`run_tool_call_loop`] with the per-agent
/// filter and extra-tool plumbing disabled — i.e. the LLM sees the entire
/// `tools_registry` unchanged. Used by legacy call sites and harness tests
/// that don't need agent-aware scoping.
#[allow(clippy::too_many_arguments)]
pub(crate) async fn agent_turn(
provider: &dyn Provider,
@@ -48,12 +52,40 @@ pub(crate) async fn agent_turn(
multimodal_config,
max_tool_iterations,
None,
None,
&[],
)
.await
}
/// Execute a single turn of the agent loop: send messages, parse tool calls,
/// execute tools, and loop until the LLM produces a final text response.
///
/// # Per-agent tool scoping
///
/// The last two parameters support per-agent tool filtering without
/// requiring callers to build a filtered copy of the (non-`Clone`able)
/// tool registry:
///
/// * `visible_tool_names` — optional whitelist of tool names that are
/// allowed to reach the LLM. When `Some(set)`, only tools whose
/// `name()` is present in the set contribute to the function-calling
/// schema and are eligible for execution; every other tool in the
/// registry is hidden from the model and rejected if the model
/// somehow emits a call for it. When `None`, no filtering is applied
/// and every tool in the combined registry is visible (the legacy
/// behaviour used by CLI/REPL and harness tests).
///
/// * `extra_tools` — per-turn synthesised tools to splice alongside the
/// persistent `tools_registry`. The agent-dispatch path uses this to
/// surface delegation tools (`research`, `delegate_gmail`, …) that
/// are synthesised fresh per turn from the active agent's
/// `subagents` field and the current Composio integration list, and
/// therefore are not registered in the global startup-time registry.
///
/// The combined tool list seen by the LLM this turn is
/// `tools_registry.iter().chain(extra_tools.iter())`, further narrowed
/// by `visible_tool_names` when supplied.
#[allow(clippy::too_many_arguments)]
pub(crate) async fn run_tool_call_loop(
provider: &dyn Provider,
@@ -68,6 +100,8 @@ pub(crate) async fn run_tool_call_loop(
multimodal_config: &crate::openhuman::config::MultimodalConfig,
max_tool_iterations: usize,
on_delta: Option<tokio::sync::mpsc::Sender<String>>,
visible_tool_names: Option<&HashSet<String>>,
extra_tools: &[Box<dyn Tool>],
) -> Result<String> {
let max_iterations = if max_tool_iterations == 0 {
DEFAULT_MAX_TOOL_ITERATIONS
@@ -75,16 +109,34 @@ pub(crate) async fn run_tool_call_loop(
max_tool_iterations
};
let tool_specs: Vec<crate::openhuman::tools::ToolSpec> =
tools_registry.iter().map(|tool| tool.spec()).collect();
// Is a given tool name visible to the model this turn? `None`
// means no filter (legacy behaviour = everything visible).
let is_visible = |name: &str| -> bool {
match visible_tool_names {
Some(set) => set.contains(name),
None => true,
}
};
let tool_specs: Vec<crate::openhuman::tools::ToolSpec> = tools_registry
.iter()
.chain(extra_tools.iter())
.filter(|tool| is_visible(tool.name()))
.map(|tool| tool.spec())
.collect();
let use_native_tools = provider.supports_native_tools() && !tool_specs.is_empty();
log::debug!(
"[tool-loop] Registry has {} tool(s): [{}]",
"[tool-loop] Registry has {} tool(s), extra {} tool(s), filter={} — {} visible in schema: [{}]",
tools_registry.len(),
tools_registry
extra_tools.len(),
visible_tool_names
.map(|s| format!("whitelist({})", s.len()))
.unwrap_or_else(|| "none".to_string()),
tool_specs.len(),
tool_specs
.iter()
.map(|t| t.name())
.map(|s| s.name.as_str())
.collect::<Vec<_>>()
.join(", ")
);
@@ -289,7 +341,16 @@ pub(crate) async fn run_tool_call_loop(
}
}
let tool_opt = find_tool(tools_registry, &call.name);
// Look up the tool by name in the combined registry + extras,
// subject to the visibility whitelist. If the model hallucinated
// a filtered-out tool name we treat it as unknown — the error
// path below produces a structured error message the LLM can
// correct in the next iteration.
let tool_opt: Option<&dyn Tool> = tools_registry
.iter()
.chain(extra_tools.iter())
.find(|t| t.name() == call.name && is_visible(t.name()))
.map(|b| b.as_ref());
tracing::debug!(
iteration,
tool = call.name.as_str(),
@@ -563,6 +624,8 @@ mod tests {
&crate::openhuman::config::MultimodalConfig::default(),
1,
None,
None,
&[],
)
.await
.expect_err("vision markers should be rejected");
@@ -597,6 +660,8 @@ mod tests {
&crate::openhuman::config::MultimodalConfig::default(),
1,
Some(tx),
None,
&[],
)
.await
.expect("final text should succeed");
@@ -646,6 +711,8 @@ mod tests {
&crate::openhuman::config::MultimodalConfig::default(),
2,
None,
None,
&[],
)
.await
.expect("loop should recover after denial");
@@ -698,6 +765,8 @@ mod tests {
&crate::openhuman::config::MultimodalConfig::default(),
2,
None,
None,
&[],
)
.await
.expect("native tool flow should succeed");
@@ -753,6 +822,8 @@ mod tests {
&crate::openhuman::config::MultimodalConfig::default(),
2,
None,
None,
&[],
)
.await
.expect("non-cli channels should auto-approve supervised tools");
@@ -801,6 +872,8 @@ mod tests {
&crate::openhuman::config::MultimodalConfig::default(),
0,
None,
None,
&[],
)
.await
.expect("default iteration fallback should still succeed");
@@ -853,6 +926,8 @@ mod tests {
&crate::openhuman::config::MultimodalConfig::default(),
2,
None,
None,
&[],
)
.await
.expect("loop should recover after tool errors");
@@ -889,6 +964,8 @@ mod tests {
&crate::openhuman::config::MultimodalConfig::default(),
1,
None,
None,
&[],
)
.await
.expect_err("provider error path should fail");
@@ -918,6 +995,8 @@ mod tests {
&crate::openhuman::config::MultimodalConfig::default(),
1,
None,
None,
&[],
)
.await
.expect_err("loop should stop after configured iterations");
+8
View File
@@ -229,6 +229,14 @@ pub async fn run_triage_with_resolved(
// cap is only a safety net.
max_tool_iterations: 1,
on_delta: None,
// The triage classifier runs against an empty tools registry
// by design and emits a structured JSON decision rather than
// calling tools — record the agent identity for tracing but
// leave the visible-tool filter unset so the legacy unfiltered
// behaviour is preserved.
target_agent_id: Some("trigger_triage".to_string()),
visible_tool_names: None,
extra_tools: Vec::new(),
};
tracing::debug!(
provider = %provider_name,
+19 -1
View File
@@ -1,4 +1,6 @@
use std::fs::{self, File};
use std::fs;
#[cfg(unix)]
use std::fs::File;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
@@ -170,11 +172,27 @@ fn load_stored_app_state(config: &Config) -> Result<StoredAppState, String> {
}
fn sync_parent_dir(path: &Path) -> Result<(), String> {
// Directory fsync is a POSIX-only durability guarantee — on Unix we
// open the parent dir and call `sync_all()` so the rename of the
// temp file into place is persisted even if the host crashes before
// the next buffer flush. On Windows, opening a directory as a
// regular file requires `FILE_FLAG_BACKUP_SEMANTICS` which
// `std::fs::File::open` does not set, so the call fails with
// "Access is denied. (os error 5)". Since Windows uses a different
// durability model (and `NamedTempFile::persist` issues an atomic
// MoveFileEx which is already durable enough for our config files),
// we skip the fsync entirely on non-Unix and return Ok. Mirrors the
// existing `sync_directory` guard in `config/schema/load.rs`.
#[cfg(unix)]
if let Some(parent) = path.parent() {
File::open(parent)
.and_then(|dir| dir.sync_all())
.map_err(|e| format!("failed to sync directory {}: {e}", parent.display()))?;
}
#[cfg(not(unix))]
{
let _ = path;
}
Ok(())
}
+105 -3
View File
@@ -32,6 +32,36 @@ struct SessionEntry {
agent: Agent,
model_override: Option<String>,
temperature: Option<f64>,
/// Which agent definition was used to build `agent`. Recorded so
/// that the cache hit predicate in `run_chat_task` can detect
/// when the routing decision (welcome vs orchestrator) flips
/// between turns and rebuild instead of reusing a stale agent.
/// Without this field the cache hit short-circuited the routing
/// fix from Commit 8 — the very first turn picked welcome,
/// welcome called `complete_onboarding(complete)`, the flag
/// flipped, but the next turn read the cached welcome agent
/// instead of invoking `build_session_agent` to re-resolve the
/// target.
target_agent_id: String,
}
/// Decide which agent definition this turn should run with.
///
/// Mirrors the routing decision inside `build_session_agent` so
/// `run_chat_task` can compute it once up front and use it both as
/// the cache hit predicate AND (transitively) as the target id the
/// builder picks. Reads `chat_onboarding_completed` from a fresh
/// disk-loaded `Config` (no in-process cache) so the value reflects
/// the current persisted state — meaning the moment the welcome
/// agent calls `complete_onboarding(complete)` and the flag flips
/// to `true`, the very next chat turn observes the new value here
/// and the cache miss + rebuild routes to orchestrator.
fn pick_target_agent_id(config: &Config) -> &'static str {
if config.chat_onboarding_completed {
"orchestrator"
} else {
"welcome"
}
}
#[derive(Debug)]
@@ -241,6 +271,14 @@ async fn run_chat_task(
let map_key = key_for(client_id, thread_id);
let model_override = normalize_model_override(model_override);
// Compute the routing decision up front so the cache lookup can
// detect when it has changed. Without this, a turn that flips
// `chat_onboarding_completed` (welcome agent calling
// `complete_onboarding(complete)`) would still serve the next
// turn from the cached welcome agent — the cache hit predicate
// didn't know about the routing decision before Commit 13.
let target_agent_id = pick_target_agent_id(&config).to_string();
let prior = {
let mut sessions = THREAD_SESSIONS.lock().await;
sessions.remove(&map_key)
@@ -248,11 +286,35 @@ async fn run_chat_task(
let mut agent = match prior {
Some(entry)
if entry.model_override == model_override && entry.temperature == temperature =>
if entry.model_override == model_override
&& entry.temperature == temperature
&& entry.target_agent_id == target_agent_id =>
{
log::info!(
"[web-channel] reusing cached session agent id={} for client={} thread={}",
target_agent_id,
client_id,
thread_id
);
entry.agent
}
Some(_) | None => build_session_agent(
Some(prior_entry) => {
log::info!(
"[web-channel] cache miss — rebuilding session agent (was id={}, now id={}) for client={} thread={}",
prior_entry.target_agent_id,
target_agent_id,
client_id,
thread_id
);
build_session_agent(
&config,
client_id,
thread_id,
model_override.clone(),
temperature,
)?
}
None => build_session_agent(
&config,
client_id,
thread_id,
@@ -286,6 +348,7 @@ async fn run_chat_task(
agent,
model_override,
temperature,
target_agent_id,
},
);
}
@@ -508,7 +571,46 @@ fn build_session_agent(
effective.default_temperature = temp;
}
Agent::from_config(&effective)
// Route to welcome vs orchestrator based on the per-user
// **chat-onboarding** flag. #525 fix: pre-onboarding users see the
// welcome agent's persona with its 2-tool TOML scope
// (complete_onboarding + memory_recall) instead of the
// orchestrator's default delegation surface. Post-onboarding they
// transition automatically on the next chat turn because
// `Config::load_or_init` reads fresh from disk every call.
//
// We deliberately read `chat_onboarding_completed`, NOT
// `onboarding_completed`. The latter is the React UI wizard's
// gate (`OnboardingOverlay.tsx`) which flips to `true` the moment
// the user dismisses the wizard — which happens BEFORE they ever
// type in the chat pane. If we routed on that flag the welcome
// agent could never run from the Tauri desktop app. The chat
// flag is set only by the welcome agent itself via
// `complete_onboarding(action="complete")`, so it stays `false`
// for the user's actual first chat message regardless of what
// the React layer did, then flips on the welcome turn so the
// very next message routes to orchestrator.
//
// The config reached here has already been loaded by
// `run_chat_task` via `config_rpc::load_config_with_timeout`, so
// both flags reflect the current persisted state — no cache to
// invalidate.
let target_agent_id = if effective.chat_onboarding_completed {
"orchestrator"
} else {
"welcome"
};
log::info!(
"[web-channel] routing chat turn to '{}' (chat_onboarding_completed={}, ui_onboarding_completed={}, client_id={}, thread_id={})",
target_agent_id,
effective.chat_onboarding_completed,
effective.onboarding_completed,
client_id,
thread_id
);
Agent::from_config_for_agent(&effective, target_agent_id)
.map(|mut agent| {
agent.set_event_context(event_session_id_for(client_id, thread_id), "web_channel");
agent
+401
View File
@@ -4,6 +4,9 @@ use crate::core::event_bus::{
publish_global, request_native_global, DomainEvent, NativeRequestError,
};
use crate::openhuman::agent::bus::{AgentTurnRequest, AgentTurnResponse, AGENT_RUN_TURN_METHOD};
use crate::openhuman::agent::harness::definition::{
AgentDefinition, AgentDefinitionRegistry, ToolScope,
};
use crate::openhuman::channels::context::{
build_memory_context, compact_sender_history, conversation_history_key,
conversation_memory_key, is_context_window_overflow_error, ChannelRuntimeContext,
@@ -14,8 +17,12 @@ use crate::openhuman::channels::routes::{
};
use crate::openhuman::channels::traits;
use crate::openhuman::channels::{Channel, SendMessage};
use crate::openhuman::composio::fetch_connected_integrations;
use crate::openhuman::config::Config;
use crate::openhuman::providers::{self, ChatMessage};
use crate::openhuman::tools::{orchestrator_tools, Tool};
use crate::openhuman::util::truncate_with_ellipsis;
use std::collections::HashSet;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio_util::sync::CancellationToken;
@@ -176,6 +183,391 @@ fn spawn_scoped_typing_task(
handle
}
/// Per-turn scoping fields derived from the active agent definition.
///
/// Carries the three new fields that get spliced into [`AgentTurnRequest`]
/// in [`process_channel_message`]. Constructed by [`resolve_target_agent`]
/// after reading `config.onboarding_completed`, looking up the matching
/// definition in [`AgentDefinitionRegistry`], and synthesising any
/// per-turn delegation tools the agent needs.
struct AgentScoping {
target_agent_id: Option<String>,
visible_tool_names: Option<HashSet<String>>,
extra_tools: Vec<Box<dyn Tool>>,
}
impl AgentScoping {
/// Empty scoping — preserves the legacy "every tool in the global
/// registry is visible" behaviour. Returned when the registry isn't
/// initialised yet (early startup) or when the target agent
/// definition isn't found, so the channel layer never crashes the
/// runtime over a routing miss.
fn unscoped() -> Self {
Self {
target_agent_id: None,
visible_tool_names: None,
extra_tools: Vec::new(),
}
}
}
/// Decide which agent should run for this channel turn and build the
/// matching tool-scoping payload.
///
/// The selection is purely a function of
/// `config.chat_onboarding_completed`:
///
/// * **`false`** → route to the `welcome` agent. Welcome's TOML
/// restricts it to two tools (`complete_onboarding`, `memory_recall`)
/// so the LLM cannot accidentally send messages or write files
/// while guiding the user through setup. The welcome agent decides
/// when the user is ready and calls
/// `complete_onboarding(action="complete")`, which flips the flag.
///
/// * **`true`** → route to the `orchestrator` agent. Orchestrator
/// delegates real work to specialist subagents via a `subagents`
/// field in its TOML; this function expands that field into a list
/// of `delegate_*` tools spliced alongside the global registry.
///
/// We deliberately read `chat_onboarding_completed` and NOT the
/// React-UI-managed `onboarding_completed` flag. The latter is the
/// gate `OnboardingOverlay.tsx` uses to render its full-screen wizard
/// in the Tauri desktop app — by the time a desktop user can type a
/// chat message it's already `true`, so routing on it would mean
/// welcome could never run from the Tauri app. The chat flag is set
/// exclusively by the welcome agent itself when it calls
/// `complete_onboarding(complete)`, so it stays `false` for the
/// user's actual first message regardless of what the React layer
/// did. See `Config::chat_onboarding_completed` rustdoc for the full
/// rationale.
///
/// The next channel message after `complete_onboarding` flips the
/// flag is automatically routed to the orchestrator because
/// `Config::load_or_init()` reads from disk every call (no in-process
/// cache, verified at `config/schema/load.rs:409`), so the new value
/// is observed on the next turn without any explicit handoff event.
///
/// On any failure path (missing registry, missing definition, missing
/// orchestrator delegation targets) the function logs and returns
/// [`AgentScoping::unscoped`], which lets the turn run with the legacy
/// unfiltered behaviour rather than failing the whole message.
async fn resolve_target_agent(channel: &str) -> AgentScoping {
let config = match Config::load_or_init().await {
Ok(c) => c,
Err(err) => {
tracing::warn!(
channel = %channel,
error = %err,
"[dispatch::routing] failed to load config — falling back to unscoped turn"
);
return AgentScoping::unscoped();
}
};
let target_id = if config.chat_onboarding_completed {
"orchestrator"
} else {
"welcome"
};
tracing::info!(
channel = %channel,
target_agent = target_id,
chat_onboarding_completed = config.chat_onboarding_completed,
ui_onboarding_completed = config.onboarding_completed,
"[dispatch::routing] selected target agent"
);
let registry = match AgentDefinitionRegistry::global() {
Some(reg) => reg,
None => {
tracing::warn!(
channel = %channel,
target_agent = target_id,
"[dispatch::routing] AgentDefinitionRegistry not initialised — falling back to unscoped turn"
);
return AgentScoping::unscoped();
}
};
let definition = match registry.get(target_id) {
Some(def) => def,
None => {
tracing::warn!(
channel = %channel,
target_agent = target_id,
"[dispatch::routing] target agent not in registry — falling back to unscoped turn"
);
return AgentScoping::unscoped();
}
};
// Synthesise per-turn delegation tools when the target agent has a
// `subagents = [...]` field. Today only the orchestrator does, but
// the helper is agent-agnostic so future agents that delegate
// (e.g. a custom workspace-override planner that subdivides work)
// pick this up for free.
let extra_tools = if !definition.subagents.is_empty() {
let connected = fetch_connected_integrations(&config).await;
tracing::debug!(
channel = %channel,
target_agent = target_id,
connected_integration_count = connected.len(),
"[dispatch::routing] fetched connected integrations for delegation expansion"
);
orchestrator_tools::collect_orchestrator_tools(definition, registry, &connected)
} else {
Vec::new()
};
let visible_tool_names = build_visible_tool_set(definition, &extra_tools);
tracing::debug!(
channel = %channel,
target_agent = target_id,
named_tool_count = match &definition.tools {
ToolScope::Named(names) => names.len(),
ToolScope::Wildcard => 0,
},
extra_tool_count = extra_tools.len(),
visible_tool_count = visible_tool_names.as_ref().map(|s| s.len()).unwrap_or(0),
"[dispatch::routing] assembled tool scoping for turn"
);
AgentScoping {
target_agent_id: Some(target_id.to_string()),
visible_tool_names,
extra_tools,
}
}
/// Build the visible-tool whitelist for an agent.
///
/// The set is the union of:
/// * every tool name in the agent's `[tools] named = [...]` list
/// (when the scope is [`ToolScope::Named`]); and
/// * every name produced by the per-turn synthesised delegation tools
/// in `extra_tools` (e.g. `research`, `delegate_gmail`).
///
/// When the agent's tool scope is [`ToolScope::Wildcard`] **and** there
/// are no `extra_tools`, returns `None` to preserve the legacy
/// "everything visible" semantics — a `Wildcard` agent that delegates
/// nothing should still see the full registry. When `Wildcard` is
/// combined with non-empty extras (an unusual but legal combination),
/// the legacy unfiltered behaviour also wins because the wildcard
/// implicitly covers anything in the registry plus the extras.
fn build_visible_tool_set(
definition: &AgentDefinition,
extra_tools: &[Box<dyn Tool>],
) -> Option<HashSet<String>> {
match &definition.tools {
ToolScope::Wildcard => None,
ToolScope::Named(names) => {
let mut set: HashSet<String> = names.iter().cloned().collect();
for tool in extra_tools {
set.insert(tool.name().to_string());
}
Some(set)
}
}
}
#[cfg(test)]
mod scoping_tests {
//! Pure-function unit tests for the agent-scoping helpers added by
//! the #525/#526 fix. These exercise the synchronous logic without
//! touching the real `Config::load_or_init` disk read or the global
//! `AgentDefinitionRegistry`, so they can run in any environment.
//!
//! End-to-end exercise of the dispatch path is covered by the
//! existing `runtime_dispatch::dispatch_routes_through_agent_run_turn_
//! bus_handler` integration test, which still passes after the new
//! fields landed (the resolver gracefully falls back to
//! `AgentScoping::unscoped()` when no orchestrator is registered in
//! the test environment).
use super::*;
use crate::openhuman::agent::harness::definition::{
DefinitionSource, ModelSpec, PromptSource, SandboxMode,
};
use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolCategory, ToolResult};
use async_trait::async_trait;
/// Minimal owned tool stub — just enough for `build_visible_tool_set`
/// to read its `name()`.
struct StubTool {
name: &'static str,
}
#[async_trait]
impl Tool for StubTool {
fn name(&self) -> &str {
self.name
}
fn description(&self) -> &str {
"stub"
}
fn parameters_schema(&self) -> serde_json::Value {
serde_json::json!({"type": "object"})
}
fn category(&self) -> ToolCategory {
ToolCategory::System
}
fn permission_level(&self) -> PermissionLevel {
PermissionLevel::None
}
async fn execute(&self, _args: serde_json::Value) -> anyhow::Result<ToolResult> {
Ok(ToolResult::success("ok"))
}
}
fn def_with_scope(scope: ToolScope) -> AgentDefinition {
AgentDefinition {
id: "test_agent".into(),
when_to_use: "test".into(),
display_name: None,
system_prompt: PromptSource::Inline(String::new()),
omit_identity: true,
omit_memory_context: true,
omit_safety_preamble: true,
omit_skills_catalog: true,
model: ModelSpec::Inherit,
temperature: 0.4,
tools: scope,
disallowed_tools: vec![],
skill_filter: None,
category_filter: None,
max_iterations: 8,
timeout_secs: None,
sandbox_mode: SandboxMode::None,
background: false,
uses_fork_context: false,
subagents: vec![],
delegate_name: None,
source: DefinitionSource::Builtin,
}
}
/// `ToolScope::Wildcard` must yield `None` — the prompt builder
/// treats `None` as "no filter, every tool visible", which is the
/// correct behaviour for agents like `skills_agent` that want the
/// full skill-category catalogue. Even when extras are present, a
/// wildcard agent should not start filtering.
#[test]
fn wildcard_scope_yields_none_filter() {
let def = def_with_scope(ToolScope::Wildcard);
let extras: Vec<Box<dyn Tool>> = vec![Box::new(StubTool { name: "research" })];
assert!(build_visible_tool_set(&def, &extras).is_none());
assert!(build_visible_tool_set(&def, &[]).is_none());
}
/// `ToolScope::Named` with no extras returns exactly the named set.
/// This is the welcome agent's path: 2 tools in TOML, no
/// delegation, no extras → 2 entries in the visibility whitelist.
#[test]
fn named_scope_without_extras_returns_named_only() {
let def = def_with_scope(ToolScope::Named(vec![
"complete_onboarding".into(),
"memory_recall".into(),
]));
let set = build_visible_tool_set(&def, &[]).expect("named scope yields Some");
assert_eq!(set.len(), 2);
assert!(set.contains("complete_onboarding"));
assert!(set.contains("memory_recall"));
}
/// `ToolScope::Named` with extras returns the union of the TOML
/// named list and the extras' names. This is the orchestrator's
/// path: 4 direct tools from the TOML + N synthesised delegation
/// tools (`research`, `plan`, `delegate_gmail`, …) → all of them
/// visible to the orchestrator's LLM.
#[test]
fn named_scope_with_extras_returns_union() {
let def = def_with_scope(ToolScope::Named(vec![
"query_memory".into(),
"ask_user_clarification".into(),
"spawn_subagent".into(),
]));
let extras: Vec<Box<dyn Tool>> = vec![
Box::new(StubTool { name: "research" }),
Box::new(StubTool {
name: "delegate_gmail",
}),
Box::new(StubTool {
name: "delegate_github",
}),
];
let set = build_visible_tool_set(&def, &extras).expect("named scope yields Some");
assert_eq!(set.len(), 6);
assert!(set.contains("query_memory"));
assert!(set.contains("ask_user_clarification"));
assert!(set.contains("spawn_subagent"));
assert!(set.contains("research"));
assert!(set.contains("delegate_gmail"));
assert!(set.contains("delegate_github"));
}
/// Empty `Named` list with extras still yields `Some` containing
/// just the extras — useful for hypothetical agents that only
/// reach the world via delegation, with no direct tools.
#[test]
fn empty_named_with_extras_returns_extras_only() {
let def = def_with_scope(ToolScope::Named(vec![]));
let extras: Vec<Box<dyn Tool>> = vec![Box::new(StubTool {
name: "delegate_only",
})];
let set = build_visible_tool_set(&def, &extras).expect("named scope yields Some");
assert_eq!(set.len(), 1);
assert!(set.contains("delegate_only"));
}
/// Empty `Named` list with no extras yields an empty `Some(set)` —
/// effectively "no tools visible". The prompt loop's `is_visible`
/// helper treats `Some(empty)` differently from `None`: the former
/// means "filter active, nothing matches" so the LLM gets an empty
/// tool list, while the latter means "no filter at all". This is
/// the welcome agent's emergency fallback if its TOML somehow
/// shipped without any tools.
#[test]
fn empty_named_with_no_extras_returns_empty_set() {
let def = def_with_scope(ToolScope::Named(vec![]));
let set = build_visible_tool_set(&def, &[]).expect("named scope yields Some");
assert!(set.is_empty());
}
/// Duplicate names across named + extras are de-duplicated by the
/// HashSet — no double-counting if a workspace override happens to
/// list a delegation tool name in the direct `named` list too.
#[test]
fn duplicate_names_across_named_and_extras_are_deduplicated() {
let def = def_with_scope(ToolScope::Named(vec![
"research".into(),
"query_memory".into(),
]));
let extras: Vec<Box<dyn Tool>> = vec![
Box::new(StubTool { name: "research" }), // collides with named
Box::new(StubTool { name: "plan" }),
];
let set = build_visible_tool_set(&def, &extras).expect("named scope yields Some");
assert_eq!(set.len(), 3);
assert!(set.contains("research"));
assert!(set.contains("query_memory"));
assert!(set.contains("plan"));
}
/// `AgentScoping::unscoped` is the safe-fallback constructor used
/// when the registry is uninitialised or the target agent isn't
/// found. All three fields must default to "no scoping applied"
/// so the channel turn runs with the legacy unfiltered behaviour.
#[test]
fn agent_scoping_unscoped_has_no_filter_or_extras() {
let scoping = AgentScoping::unscoped();
assert!(scoping.target_agent_id.is_none());
assert!(scoping.visible_tool_names.is_none());
assert!(scoping.extra_tools.is_empty());
}
}
pub(crate) async fn process_channel_message(
ctx: Arc<ChannelRuntimeContext>,
msg: traits::ChannelMessage,
@@ -377,6 +769,12 @@ pub(crate) async fn process_channel_message(
// The agent handler owns the history vector — we `mem::take` the
// local one to avoid an unnecessary clone; `history` is not read
// again below.
// Pick the active agent for this turn (welcome pre-onboarding,
// orchestrator post) and synthesise its delegation tool surface.
// Fresh disk read of `Config::onboarding_completed` happens inside
// `resolve_target_agent` — see the `[dispatch::routing]` traces.
let scoping = resolve_target_agent(&msg.channel).await;
let turn_request = AgentTurnRequest {
provider: Arc::clone(&active_provider),
history: std::mem::take(&mut history),
@@ -389,6 +787,9 @@ pub(crate) async fn process_channel_message(
multimodal: ctx.multimodal.clone(),
max_tool_iterations: ctx.max_tool_iterations,
on_delta: delta_tx,
target_agent_id: scoping.target_agent_id,
visible_tool_names: scoping.visible_tool_names,
extra_tools: scoping.extra_tools,
};
tracing::debug!(
channel = %msg.channel,
+51 -1
View File
@@ -142,9 +142,58 @@ pub struct Config {
#[serde(default)]
pub dictation: DictationConfig,
/// Whether the user has completed the onboarding flow.
/// Whether the user has completed the **React UI** onboarding flow.
///
/// Set by `OnboardingOverlay.tsx::handleDone` and the multi-step
/// `Onboarding.tsx` wizard via the `config.set_onboarding_completed`
/// JSON-RPC method. Gates whether the React layer renders the
/// full-screen onboarding overlay on top of the chat pane: when
/// `false`, the overlay is shown and the user cannot interact with
/// the chat until they complete or defer the wizard.
///
/// Distinct from [`Config::chat_onboarding_completed`] — this flag
/// only tracks the UI wizard, NOT the welcome agent's chat-based
/// greeting flow. See that field for the agent routing semantics.
#[serde(default)]
pub onboarding_completed: bool,
/// Whether the **chat-based welcome agent** flow has run for this
/// user. Distinct from [`Config::onboarding_completed`] (the
/// React UI wizard flag) so the welcome agent can run on the very
/// first chat turn even after the React wizard has already
/// completed.
///
/// Routing semantics:
/// * **`false`** — incoming channel messages and Tauri in-app
/// chat turns route to the `welcome` agent definition (see
/// `channels::providers::web::build_session_agent` and
/// `channels::runtime::dispatch::resolve_target_agent`). The
/// welcome agent inspects the user's setup, delivers a
/// personalized greeting, and (when the essentials are in
/// place) calls `complete_onboarding(action="complete")` which
/// flips this flag to `true`.
/// * **`true`** — the welcome agent has already run; future chat
/// turns route to the orchestrator.
///
/// Why two separate flags:
///
/// In the Tauri desktop app, `OnboardingOverlay` blocks the chat
/// pane until `onboarding_completed=true`. If the welcome agent
/// also gated on `onboarding_completed`, by the time the user
/// could type in chat the flag would already be `true` and the
/// welcome agent would never run on the desktop. Using a separate
/// flag lets the React wizard manage UI gating while the chat
/// welcome runs orthogonally — every user gets greeted by the
/// welcome agent on their first chat turn regardless of which
/// surface they came from (web, Telegram, Discord, etc.).
///
/// Defaults to `false` for backward compatibility — existing
/// `config.toml` files without this field will get the welcome
/// agent on their next chat turn, which is the correct behaviour
/// (the welcome agent is idempotent and re-running it for an
/// already-onboarded user just produces a recognition message).
#[serde(default)]
pub chat_onboarding_completed: bool,
}
impl Default for Config {
@@ -196,6 +245,7 @@ impl Default for Config {
update: UpdateConfig::default(),
dictation: DictationConfig::default(),
onboarding_completed: false,
chat_onboarding_completed: false,
}
}
}
+188 -11
View File
@@ -237,19 +237,39 @@ pub async fn dump_agent_prompt(options: DumpPromptOptions) -> Result<DumpedPromp
// ── Fetch connected integrations ────────────────────────────────────
let connected_integrations = fetch_connected_integrations_for_dump(&config).await;
// Load the agent definition registry once. Both the main-agent and
// sub-agent paths consult it now: after #525/#526 the "main" dump
// routes through the same definition-driven filter as every other
// agent so what `dump-prompt main` shows matches what the runtime
// dispatch path actually feeds the LLM.
let registry = AgentDefinitionRegistry::load(&workspace_dir)
.context("loading agent definition registry for prompt dump")?;
// ── Main agent path ────────────────────────────────────────────────
if options.agent_id == "main" || options.agent_id == "orchestrator_main" {
let orchestrator = registry.get("orchestrator").cloned().ok_or_else(|| {
anyhow!(
"orchestrator definition not in registry — cannot render main dump. \
Known agents: [{}]",
registry
.list()
.iter()
.map(|d| d.id.as_str())
.collect::<Vec<_>>()
.join(", ")
)
})?;
return render_main_agent_dump(
&workspace_dir,
&model_name,
&tools_vec,
&connected_integrations,
&registry,
&orchestrator,
);
}
// ── Sub-agent path ────────────────────────────────────────────────
let registry = AgentDefinitionRegistry::load(&workspace_dir)
.context("loading agent definition registry for prompt dump")?;
let definition = registry
.get(&options.agent_id)
.cloned()
@@ -336,11 +356,46 @@ fn render_main_agent_dump(
model_name: &str,
tools_vec: &[Box<dyn Tool>],
connected_integrations: &[ConnectedIntegration],
registry: &AgentDefinitionRegistry,
orchestrator_def: &AgentDefinition,
) -> Result<DumpedPrompt> {
let prompt_tools = PromptTool::from_tools(tools_vec);
// Main agent dumps do not apply a visible-tool filter — every
// tool the registry emits is candidate for rendering.
let empty_filter: HashSet<String> = HashSet::new();
// Synthesise per-turn delegation tools the same way `dispatch::
// resolve_target_agent` does at runtime, so the dump reflects the
// exact tool surface the orchestrator's LLM sees in production:
// the agent's `[tools] named` list the names of the synthesised
// delegate_* tools (research, plan, run_code, … plus
// delegate_<toolkit> per connected Composio integration).
let extras = crate::openhuman::tools::orchestrator_tools::collect_orchestrator_tools(
orchestrator_def,
registry,
connected_integrations,
);
// Build the prompt tool list from the global registry plus the
// synthesised extras. The extras Vec must outlive `prompt_tools`
// because PromptTool borrows tool name + description fields, and
// both sources need to live until `build_with_cache_metadata`
// finishes consuming the PromptContext.
let mut prompt_tools = PromptTool::from_tools(tools_vec);
let extras_prompts = PromptTool::from_tools(&extras);
prompt_tools.extend(extras_prompts);
// Build the visibility whitelist from the orchestrator definition's
// `[tools] named` list unioned with every synthesised extra. When
// the orchestrator uses `ToolScope::Wildcard` (legacy / custom
// workspace overrides), we fall back to an empty filter — which the
// prompt builder treats as "no filter, every tool visible" — so the
// dump preserves the legacy unscoped behaviour for those agents.
let visible_filter: HashSet<String> = match &orchestrator_def.tools {
ToolScope::Named(names) => {
let mut set: HashSet<String> = names.iter().cloned().collect();
for tool in &extras {
set.insert(tool.name().to_string());
}
set
}
ToolScope::Wildcard => HashSet::new(),
};
// Construct a real PFormatToolDispatcher so the dump includes the
// exact "Tool Use Protocol" preamble the runtime would inject.
@@ -399,7 +454,7 @@ fn render_main_agent_dump(
skills: &[],
dispatcher_instructions: &dispatcher_instructions,
learned,
visible_tool_names: &empty_filter,
visible_tool_names: &visible_filter,
tool_call_format: ToolCallFormat::PFormat,
connected_integrations,
};
@@ -408,10 +463,27 @@ fn render_main_agent_dump(
.build_with_cache_metadata(&ctx)
.context("building main-agent prompt")?;
let tool_names: Vec<String> = tools_vec.iter().map(|t| t.name().to_string()).collect();
// Report the tool names that survived the visibility filter so
// tests and the CLI dump output reflect what the LLM actually
// sees, not the raw global registry. When the filter is empty
// (Wildcard scope), every tool from registry + extras is reported.
let visible_predicate = |name: &str| -> bool {
if visible_filter.is_empty() {
true
} else {
visible_filter.contains(name)
}
};
let tool_names: Vec<String> = tools_vec
.iter()
.chain(extras.iter())
.filter(|t| visible_predicate(t.name()))
.map(|t| t.name().to_string())
.collect();
let skill_tool_count = tools_vec
.iter()
.filter(|t| t.category() == ToolCategory::Skill)
.chain(extras.iter())
.filter(|t| visible_predicate(t.name()) && t.category() == ToolCategory::Skill)
.count();
Ok(DumpedPrompt {
@@ -644,6 +716,8 @@ mod tests {
sandbox_mode: SandboxMode::None,
background: false,
uses_fork_context: false,
subagents: vec![],
delegate_name: None,
source: DefinitionSource::Builtin,
}
}
@@ -795,8 +869,52 @@ mod tests {
);
}
/// Helper: build a minimal AgentDefinition + registry pair the
/// `render_main_agent_dump` tests can use. The "orchestrator" entry
/// here is wildcard-scoped with no subagents so the dump runs in
/// its legacy unfiltered shape (every tool from `tools_vec` shows
/// up). Tests that exercise the per-agent filter use
/// `orchestrator_def_with_named_scope` below.
fn wildcard_orchestrator_def() -> AgentDefinition {
AgentDefinition {
id: "orchestrator".into(),
when_to_use: "test".into(),
display_name: None,
system_prompt: PromptSource::Inline(String::new()),
omit_identity: true,
omit_memory_context: true,
omit_safety_preamble: true,
omit_skills_catalog: true,
model: ModelSpec::Inherit,
temperature: 0.4,
tools: ToolScope::Wildcard,
disallowed_tools: vec![],
skill_filter: None,
category_filter: None,
max_iterations: 8,
timeout_secs: None,
sandbox_mode: SandboxMode::None,
background: false,
uses_fork_context: false,
subagents: vec![],
delegate_name: None,
source: DefinitionSource::Builtin,
}
}
fn registry_with_orchestrator(orch: AgentDefinition) -> AgentDefinitionRegistry {
let mut reg = AgentDefinitionRegistry::default();
reg.insert(orch);
reg
}
/// Wildcard scope path: the dump should report every tool from
/// `tools_vec` (no filter applied) and produce the standard
/// system-prompt skeleton with the Tool Use Protocol section. This
/// preserves the legacy main-dump assertions for orchestrator
/// definitions that opt out of the named filter.
#[test]
fn render_main_agent_dump_includes_tool_instructions_and_skill_count() {
fn render_main_agent_dump_wildcard_scope_shows_full_tool_set() {
let workspace =
std::env::temp_dir().join(format!("openhuman_debug_main_{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&workspace).unwrap();
@@ -816,7 +934,11 @@ mod tests {
}),
];
let dumped = render_main_agent_dump(&workspace, "reasoning-v1", &tools, &[]).unwrap();
let orch = wildcard_orchestrator_def();
let registry = registry_with_orchestrator(orch.clone());
let dumped =
render_main_agent_dump(&workspace, "reasoning-v1", &tools, &[], &registry, &orch)
.unwrap();
assert_eq!(dumped.mode, "main");
assert_eq!(dumped.model, "reasoning-v1");
assert_eq!(dumped.tool_names, vec!["shell", "notion__create_page"]);
@@ -828,6 +950,55 @@ mod tests {
let _ = std::fs::remove_dir_all(workspace);
}
/// Named-scope path (the new behaviour after #525/#526): the
/// orchestrator definition restricts the LLM to a small whitelist,
/// and the dump must reflect that — `shell` is in `tools_vec` but
/// not in the orchestrator's `named` list, so it must NOT appear
/// in `tool_names`. This is the regression guard for the
/// "render_main_agent_dump uses empty_filter so the catalogue
/// leaks" bug at the heart of #526.
#[test]
fn render_main_agent_dump_named_scope_filters_to_whitelist() {
let workspace = std::env::temp_dir().join(format!(
"openhuman_debug_main_named_{}",
uuid::Uuid::new_v4()
));
std::fs::create_dir_all(&workspace).unwrap();
let tools: Vec<Box<dyn Tool>> = vec![
Box::new(StubTool {
name: "shell",
category: ToolCategory::System,
}),
Box::new(StubTool {
name: "query_memory",
category: ToolCategory::System,
}),
Box::new(StubTool {
name: "GMAIL_SEND_EMAIL",
category: ToolCategory::Skill,
}),
];
let mut orch = wildcard_orchestrator_def();
orch.tools = ToolScope::Named(vec!["query_memory".into(), "ask_user_clarification".into()]);
let registry = registry_with_orchestrator(orch.clone());
let dumped =
render_main_agent_dump(&workspace, "reasoning-v1", &tools, &[], &registry, &orch)
.unwrap();
// `shell` and `GMAIL_SEND_EMAIL` are in the global tools_vec
// but NOT in the orchestrator's named whitelist → must be
// excluded from the dump output. Only `query_memory` survives
// (the other named entry, `ask_user_clarification`, isn't in
// tools_vec at all so nothing to render for it).
assert_eq!(dumped.tool_names, vec!["query_memory"]);
assert_eq!(dumped.skill_tool_count, 0);
let _ = std::fs::remove_dir_all(workspace);
}
#[test]
fn filter_respects_named_scope_and_disallowed_tools() {
let tools: Vec<Box<dyn Tool>> = vec![
@@ -890,6 +1061,8 @@ mod tests {
sandbox_mode: SandboxMode::None,
background: false,
uses_fork_context: false,
subagents: vec![],
delegate_name: None,
source: DefinitionSource::Builtin,
};
@@ -935,6 +1108,8 @@ mod tests {
sandbox_mode: SandboxMode::None,
background: false,
uses_fork_context: false,
subagents: vec![],
delegate_name: None,
source: DefinitionSource::Builtin,
};
@@ -988,6 +1163,8 @@ mod tests {
sandbox_mode: SandboxMode::None,
background: false,
uses_fork_context: false,
subagents: vec![],
delegate_name: None,
source: DefinitionSource::Builtin,
};
@@ -1,15 +1,21 @@
//! Tool: complete_onboarding — inspects workspace setup status and marks onboarding done.
//! Tool: complete_onboarding — inspects workspace setup status and (when
//! the user is authenticated) auto-finalizes the chat welcome flow.
//!
//! Used exclusively by the **welcome** agent. On `action: "check_status"` it
//! reads the current config and app state to report what the user has and
//! hasn't configured. On `action: "complete"` it flips
//! `config.onboarding_completed = true` and seeds the default proactive
//! cron jobs (morning briefing, etc.).
//! Used exclusively by the **welcome** agent. There is only one normal
//! path: call `check_status` once. The tool returns a structured JSON
//! snapshot of the user's config AND, if the user is authenticated,
//! flips `chat_onboarding_completed = true` + seeds proactive cron jobs
//! as a side effect. The welcome agent then drafts a personalised
//! welcome message based on the JSON in its next iteration. No second
//! tool call. No race conditions. No way to forget the flip.
//!
//! The legacy `complete` action is kept as a manual override for admin
//! tools and tests but the welcome agent should never call it directly.
use crate::openhuman::config::Config;
use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolResult, ToolScope};
use async_trait::async_trait;
use serde_json::json;
use serde_json::{json, Value};
pub struct CompleteOnboardingTool;
@@ -26,11 +32,69 @@ impl Tool for CompleteOnboardingTool {
}
fn description(&self) -> &str {
"Check onboarding status or mark onboarding as complete. \
Use action=\"check_status\" to inspect what the user has configured \
(API key, channels, integrations, memory, etc.) and what still needs \
attention. Use action=\"complete\" to finalize onboarding once the \
user is ready."
"Read the user's OpenHuman config snapshot and auto-finalize the \
chat welcome flow when the user is authenticated. The welcome \
agent's single tool call.\n\
\n\
**action=\"check_status\"** — returns a JSON object describing \
the user's setup state and (as a side effect when the user has \
a valid session JWT) flips `chat_onboarding_completed` to true \
+ seeds proactive agent cron jobs. Call this ONCE on your first \
iteration with no other parameters. Use the JSON to draft a \
personalised welcome message in your second iteration.\n\
\n\
The returned JSON has this shape:\n\
```\n\
{\n\
\"authenticated\": true, // bool — JWT present\n\
\"auth_source\": \"session_token\", // \"session_token\" | \"legacy_api_key\" | null\n\
\"default_model\": \"reasoning-v1\", // string\n\
\"channels_connected\": [\"telegram\"], // string[] — connected messaging platforms\n\
\"active_channel\": \"web\", // string — preferred channel for proactive messages\n\
\"integrations\": { // bool flags for each capability\n\
\"composio\": true,\n\
\"browser\": false,\n\
\"web_search\": true,\n\
\"http_request\": false,\n\
\"local_ai\": true\n\
},\n\
\"memory\": { \"backend\": \"sqlite\", \"auto_save\": true },\n\
\"delegate_agents\": [\"researcher\", \"coder\"], // configured custom agents\n\
\"ui_onboarding_completed\": true, // React wizard flag\n\
\"chat_onboarding_completed\": true, // POST-finalize value\n\
\"finalize_action\": \"flipped\" // \"flipped\" | \"already_complete\" | \"skipped_no_auth\"\n\
}\n\
```\n\
\n\
The `finalize_action` field tells you what side effect this \
call performed:\n\
* `\"flipped\"` — the user was authenticated and the chat flow \
was previously incomplete. Flag was just flipped to true and \
cron jobs were seeded. Welcome the user; the next chat turn \
will route to the orchestrator.\n\
* `\"already_complete\"` — the user was authenticated and the \
chat flow was already complete from a prior call. No state \
change. Welcome the user (this is a re-entry case).\n\
* `\"skipped_no_auth\"` — the user is not authenticated, so \
the flag was NOT flipped. The welcome agent should explain \
the auth problem to the user, point them at the desktop \
login flow, and stop. The next chat turn will re-route to \
welcome (because the flag is still false), so they'll get \
another chance once they log in.\n\
\n\
Use the JSON fields directly when drafting your welcome \
message. Don't quote the JSON back to the user — translate \
the field values into natural prose tailored to what they \
have and don't have. The status fields are a fact source, \
not a draft.\n\
\n\
**action=\"complete\"** — legacy manual finalize-only path. \
Flips `chat_onboarding_completed` to true unconditionally and \
seeds cron jobs without producing a status report. Returns \
the literal token \"ok\". Welcome agent should never call \
this; use `check_status` instead which performs the same \
finalize as a side effect under proper auth gating. Kept for \
backward compatibility with admin tools and tests."
}
fn parameters_schema(&self) -> serde_json::Value {
@@ -40,7 +104,7 @@ impl Tool for CompleteOnboardingTool {
"action": {
"type": "string",
"enum": ["check_status", "complete"],
"description": "\"check_status\" to inspect current setup, \"complete\" to mark onboarding done."
"description": "\"check_status\" → return JSON config snapshot AND auto-finalize the chat welcome flow when authenticated (welcome agent's only call). \"complete\" → legacy manual finalize-only; do not use from welcome agent."
}
},
"required": ["action"]
@@ -73,193 +137,212 @@ impl Tool for CompleteOnboardingTool {
}
}
/// Reads the current config and produces a human-readable status report.
/// Reads the user's config, builds a structured JSON snapshot, and
/// (when the user is authenticated) flips `chat_onboarding_completed`
/// + seeds proactive cron jobs as a side effect of the read.
///
/// This is the welcome agent's single tool call. The contract is:
///
/// 1. **Read** — load `Config`, check JWT via
/// `crate::api::jwt::get_session_token`, gather every config flag
/// the welcome message might mention.
/// 2. **Auto-finalize** — if the user is authenticated AND
/// `chat_onboarding_completed` is currently `false`, flip it to
/// `true` and spawn the proactive agent cron seeder. If the user
/// is NOT authenticated, leave the flag alone (the welcome agent
/// will explain the auth problem and the next chat turn will
/// re-run welcome).
/// 3. **Return** — JSON object with all the config fields, the
/// POST-finalize `chat_onboarding_completed` value, and a
/// `finalize_action` discriminator describing what side effect
/// happened (`flipped`, `already_complete`, or `skipped_no_auth`).
///
/// The welcome agent uses the JSON to draft a personalised welcome
/// message in the next iteration. No second tool call needed.
async fn check_status() -> anyhow::Result<ToolResult> {
let config = Config::load_or_init()
let mut config = Config::load_or_init()
.await
.map_err(|e| anyhow::anyhow!("Failed to load config: {e}"))?;
let mut report = String::new();
report.push_str("## Onboarding Status\n\n");
// ── Auth detection ────────────────────────────────────────────
//
// Two possible auth sources, in priority order:
//
// 1. The `app-session:default` profile in `auth-profiles.json` —
// the canonical inference credential, populated by the desktop
// OAuth deep-link flow's `exchange_token` Rust command. This is
// where every production inference RPC reads from.
// 2. `config.api_key` — legacy free-form provider key field, kept
// for CI / dev setups that bypass the desktop login flow.
//
// Either one counts as "authenticated" for the welcome flow.
let has_session_jwt = crate::api::jwt::get_session_token(&config)
.ok()
.flatten()
.is_some_and(|t| !t.is_empty());
let has_legacy_api_key = config.api_key.as_ref().is_some_and(|k| !k.is_empty());
let is_authenticated = has_session_jwt || has_legacy_api_key;
let auth_source: Value = if has_session_jwt {
Value::String("session_token".to_string())
} else if has_legacy_api_key {
Value::String("legacy_api_key".to_string())
} else {
Value::Null
};
// ── Core setup ──────────────────────────────────────────────────
report.push_str("### Core\n");
let has_api_key = config.api_key.as_ref().map_or(false, |k| !k.is_empty());
report.push_str(&format!(
"- API key: {}\n",
if has_api_key {
"configured ✓"
} else {
"**missing** — required for inference"
}
));
report.push_str(&format!(
"- Default model: {}\n",
config
.default_model
.as_deref()
.unwrap_or(crate::openhuman::config::DEFAULT_MODEL)
));
report.push_str(&format!(
"- Onboarding completed: {}\n",
config.onboarding_completed
));
// ── Auto-finalize side effect ─────────────────────────────────
//
// When the user is authenticated AND the chat welcome flow has
// not yet completed, delegate to the legacy `complete()` action
// — single source of truth for "what does finalize mean". This
// is the welcome → orchestrator handoff: after the flag flips,
// the dispatch layer routes future chat turns to the orchestrator
// instead of the welcome agent (see
// `web.rs::build_session_agent` and
// `dispatch.rs::resolve_target_agent`).
//
// We discard `complete()`'s `ToolResult::success("ok")` return
// value because the caller (check_status) is producing its own
// JSON snapshot — the side effect is the only thing we want.
// After the call we mirror the flip into our local `config`
// variable so the JSON snapshot below reflects the post-finalize
// state (the disk has been updated, but our in-memory copy was
// loaded before the flip).
let finalize_action = if !is_authenticated {
"skipped_no_auth"
} else if config.chat_onboarding_completed {
"already_complete"
} else {
let _ = complete().await?;
config.chat_onboarding_completed = true;
tracing::info!(
"[complete_onboarding] chat welcome flow auto-finalized via check_status (delegated to complete())"
);
"flipped"
};
// ── Channels ────────────────────────────────────────────────────
report.push_str("\n### Channels\n");
let mut connected_channels: Vec<&str> = Vec::new();
// ── Connected messaging channels ──────────────────────────────
let mut channels_connected: Vec<&str> = Vec::new();
if config.channels_config.telegram.is_some() {
connected_channels.push("Telegram");
channels_connected.push("telegram");
}
if config.channels_config.discord.is_some() {
connected_channels.push("Discord");
channels_connected.push("discord");
}
if config.channels_config.slack.is_some() {
connected_channels.push("Slack");
channels_connected.push("slack");
}
if config.channels_config.mattermost.is_some() {
connected_channels.push("Mattermost");
channels_connected.push("mattermost");
}
if config.channels_config.email.is_some() {
connected_channels.push("Email");
channels_connected.push("email");
}
if config.channels_config.whatsapp.is_some() {
connected_channels.push("WhatsApp");
channels_connected.push("whatsapp");
}
if config.channels_config.signal.is_some() {
connected_channels.push("Signal");
channels_connected.push("signal");
}
if config.channels_config.matrix.is_some() {
connected_channels.push("Matrix");
channels_connected.push("matrix");
}
if config.channels_config.imessage.is_some() {
connected_channels.push("iMessage");
channels_connected.push("imessage");
}
if config.channels_config.irc.is_some() {
connected_channels.push("IRC");
channels_connected.push("irc");
}
if config.channels_config.lark.is_some() {
connected_channels.push("Lark");
channels_connected.push("lark");
}
if config.channels_config.dingtalk.is_some() {
connected_channels.push("DingTalk");
channels_connected.push("dingtalk");
}
if config.channels_config.linq.is_some() {
connected_channels.push("Linq");
channels_connected.push("linq");
}
if config.channels_config.qq.is_some() {
connected_channels.push("QQ");
channels_connected.push("qq");
}
if connected_channels.is_empty() {
report.push_str("- No messaging channels connected yet (Telegram, Discord, Slack, etc.)\n");
} else {
report.push_str(&format!("- Connected: {}\n", connected_channels.join(", ")));
}
report.push_str(&format!(
"- Active channel for proactive messages: {}\n",
config
.channels_config
.active_channel
.as_deref()
.unwrap_or("web (default)")
));
// ── Integrations ────────────────────────────────────────────────
report.push_str("\n### Integrations\n");
let has_composio = config.composio.enabled
// ── Integrations ──────────────────────────────────────────────
let composio_enabled = config.composio.enabled
&& config
.composio
.api_key
.as_ref()
.map_or(false, |k| !k.is_empty());
report.push_str(&format!(
"- Composio (1000+ OAuth apps): {}\n",
if has_composio {
"enabled ✓"
} else {
"not configured"
}
));
report.push_str(&format!(
"- Browser automation: {}\n",
if config.browser.enabled {
"enabled ✓"
} else {
"disabled"
}
));
report.push_str(&format!(
"- Web search: {}\n",
if config.web_search.enabled {
"enabled ✓"
} else {
"disabled"
}
));
report.push_str(&format!(
"- HTTP requests: {}\n",
if config.http_request.enabled {
"enabled ✓"
} else {
"disabled"
}
));
.is_some_and(|k| !k.is_empty());
// ── Memory ──────────────────────────────────────────────────────
report.push_str("\n### Memory\n");
report.push_str(&format!("- Backend: {}\n", config.memory.backend));
report.push_str(&format!(
"- Auto-save: {}\n",
if config.memory.auto_save { "on" } else { "off" }
));
// ── Delegate agents ───────────────────────────────────────────
let delegate_agents: Vec<&str> = config.agents.keys().map(|s| s.as_str()).collect();
// ── Local AI ────────────────────────────────────────────────────
report.push_str("\n### Local AI\n");
report.push_str(&format!(
"- Local model: {}\n",
if config.local_ai.enabled {
"enabled ✓"
} else {
"not enabled"
}
));
// ── Build the JSON snapshot ───────────────────────────────────
let snapshot = json!({
"authenticated": is_authenticated,
"auth_source": auth_source,
"default_model": config
.default_model
.as_deref()
.unwrap_or(crate::openhuman::config::DEFAULT_MODEL),
"channels_connected": channels_connected,
"active_channel": config
.channels_config
.active_channel
.as_deref()
.unwrap_or("web"),
"integrations": {
"composio": composio_enabled,
"browser": config.browser.enabled,
"web_search": config.web_search.enabled,
"http_request": config.http_request.enabled,
"local_ai": config.local_ai.enabled,
},
"memory": {
"backend": config.memory.backend,
"auto_save": config.memory.auto_save,
},
"delegate_agents": delegate_agents,
"ui_onboarding_completed": config.onboarding_completed,
"chat_onboarding_completed": config.chat_onboarding_completed,
"finalize_action": finalize_action,
});
// ── Delegate agents ─────────────────────────────────────────────
if !config.agents.is_empty() {
report.push_str("\n### Delegate Agents\n");
for (name, agent_cfg) in &config.agents {
report.push_str(&format!("- {name}: model={}\n", agent_cfg.model));
}
}
let payload = serde_json::to_string_pretty(&snapshot)
.map_err(|e| anyhow::anyhow!("Failed to serialize status snapshot: {e}"))?;
tracing::debug!(
"[complete_onboarding] status report generated, length={}",
report.len()
"[complete_onboarding] check_status returned authenticated={} finalize_action={} chars={}",
is_authenticated,
finalize_action,
payload.len()
);
Ok(ToolResult::success(report))
Ok(ToolResult::success(payload))
}
/// Marks onboarding as complete and seeds proactive cron jobs.
/// Legacy manual finalize-only path. Flips `chat_onboarding_completed`
/// to true unconditionally (no auth check) and seeds proactive cron
/// jobs. Welcome agent should NOT call this — use `check_status`
/// instead, which performs the same finalize as a side effect under
/// proper auth gating. Kept for backward compatibility with admin
/// tools and tests.
async fn complete() -> anyhow::Result<ToolResult> {
let mut config = Config::load_or_init()
.await
.map_err(|e| anyhow::anyhow!("Failed to load config: {e}"))?;
if config.onboarding_completed {
tracing::debug!("[complete_onboarding] already completed — no-op");
return Ok(ToolResult::success(
"Onboarding was already marked as complete.",
));
if config.chat_onboarding_completed {
tracing::debug!("[complete_onboarding] chat welcome flow already completed — no-op");
return Ok(ToolResult::success("ok"));
}
config.onboarding_completed = true;
config.chat_onboarding_completed = true;
config
.save()
.await
.map_err(|e| anyhow::anyhow!("Failed to save config: {e}"))?;
// Seed proactive agents (morning briefing, etc.) on the false→true transition.
let seed_config = config.clone();
tokio::spawn(async move {
if let Err(e) = crate::openhuman::cron::seed::seed_proactive_agents(&seed_config) {
@@ -267,12 +350,11 @@ async fn complete() -> anyhow::Result<ToolResult> {
}
});
tracing::info!("[complete_onboarding] onboarding marked complete, proactive agents seeded");
tracing::info!(
"[complete_onboarding] chat welcome flow marked complete via legacy complete action"
);
Ok(ToolResult::success(
"Onboarding marked as complete. Morning briefing and proactive agent jobs have been \
set up. The user is all set!",
))
Ok(ToolResult::success("ok"))
}
#[cfg(test)]
+13 -26
View File
@@ -2,6 +2,7 @@ mod archetype_delegation;
mod ask_clarification;
mod complete_onboarding;
mod delegate;
mod skill_delegation;
mod spawn_subagent;
use crate::core::event_bus::{publish_global, DomainEvent};
@@ -10,34 +11,11 @@ use crate::openhuman::agent::harness::fork_context::current_parent;
use crate::openhuman::agent::harness::subagent_runner::{run_subagent, SubagentRunOptions};
use crate::openhuman::tools::traits::ToolResult;
pub(crate) const ARCHETYPE_TOOLS: &[(&str, &str, &str)] = &[
(
"research",
"researcher",
"Search the web, read docs, and gather information. Returns a dense markdown summary with sources.",
),
(
"run_code",
"code_executor",
"Write, run, debug, and test code in a sandboxed environment. Has shell, file access, and git.",
),
(
"review_code",
"critic",
"Review code changes for quality, security, and correctness. Read-only — returns findings, never edits.",
),
(
"plan",
"planner",
"Break a complex goal into a structured step-by-step plan with dependencies. Use for tasks with 3+ steps.",
),
];
pub(crate) async fn dispatch_subagent(
agent_id: &str,
tool_name: &str,
prompt: &str,
_skill_filter: Option<&str>,
skill_filter: Option<&str>,
) -> anyhow::Result<ToolResult> {
let registry = match AgentDefinitionRegistry::global() {
Some(reg) => reg,
@@ -73,14 +51,22 @@ pub(crate) async fn dispatch_subagent(
});
log::info!(
"[agent] delegating to {} via {} prompt_chars={}",
"[agent] delegating to {} via {} (skill_filter={}) prompt_chars={}",
agent_id,
tool_name,
skill_filter.unwrap_or("<none>"),
prompt.chars().count()
);
// Propagate the per-call skill filter into the subagent runner so
// that `SkillDelegationTool`s can narrow `skills_agent` to a single
// Composio toolkit (e.g. `delegate_gmail` → skills_agent +
// skill_filter="gmail"). Previously this argument was hardcoded to
// `None`, which meant the toolkit pre-selection never reached the
// subagent and skills_agent always saw the full Composio catalog —
// the downstream half of the #526 leak.
let options = SubagentRunOptions {
skill_filter_override: None,
skill_filter_override: skill_filter.map(str::to_string),
category_filter_override: None,
context: None,
task_id: Some(task_id.clone()),
@@ -122,4 +108,5 @@ pub use archetype_delegation::ArchetypeDelegationTool;
pub use ask_clarification::AskClarificationTool;
pub use complete_onboarding::CompleteOnboardingTool;
pub use delegate::DelegateTool;
pub use skill_delegation::SkillDelegationTool;
pub use spawn_subagent::SpawnSubagentTool;
-1
View File
@@ -8,7 +8,6 @@ pub mod traits;
#[path = "impl/mod.rs"]
mod implementations;
pub(crate) use implementations::agent::ARCHETYPE_TOOLS;
pub use implementations::*;
pub use ops::*;
#[allow(unused_imports)]
-17
View File
@@ -280,23 +280,6 @@ pub fn all_tools_with_runtime(
tools
}
/// Legacy allowlist — no longer the primary source of truth for
/// orchestrator tool visibility. The `from_config` builder now uses
/// `orchestrator_tools::collect_orchestrator_tools()` to generate
/// the visible tool set dynamically. Kept for backward compatibility
/// with callers that reference this constant.
pub const MAIN_AGENT_TOOL_ALLOWLIST: &[&str] = &["spawn_subagent"];
/// Filter a full tool registry down to only the tools the main agent
/// should see. Sub-agents receive the unfiltered registry via
/// `ParentExecutionContext::all_tools` and apply their own per-definition
/// whitelist in the subagent runner.
pub fn main_agent_tools(all: Vec<Box<dyn Tool>>) -> Vec<Box<dyn Tool>> {
all.into_iter()
.filter(|t| MAIN_AGENT_TOOL_ALLOWLIST.contains(&t.name()))
.collect()
}
/// Hardware peripheral tools — always empty (boards removed); config kept for compatibility.
pub async fn create_peripheral_tools(
_config: &crate::openhuman::config::PeripheralsConfig,
+314 -30
View File
@@ -1,46 +1,330 @@
//! Dynamic orchestrator tool generation.
//!
//! Instead of a single `spawn_subagent` mega-tool, this module generates
//! one tool per subagent archetype. The orchestrator's function-calling
//! schema becomes a flat list of well-named tools:
//! The orchestrator agent doesn't directly execute work — it routes it to
//! specialised sub-agents. Rather than exposing a single generic
//! `spawn_subagent(agent_id, prompt)` mega-tool, we synthesise one named
//! tool per entry in the orchestrator's `subagents = [...]` TOML field,
//! so the LLM's function-calling schema contains discoverable, well-named
//! tools like `research`, `plan`, `run_code`, `delegate_gmail`,
//! `delegate_github`, etc.
//!
//! `research`, `run_code`, `review_code`, `plan`
//! Each synthesised tool's description is pulled live from the target
//! agent's [`AgentDefinition::when_to_use`] (for
//! [`SubagentEntry::AgentId`]) or from the connected Composio toolkit
//! metadata (for [`SubagentEntry::Skills`] wildcard expansions) — so
//! descriptions automatically stay in sync with the definitions and
//! never drift from a hardcoded table.
//!
//! Each tool's `execute()` internally calls `run_subagent` with the
//! correct definition. The LLM just picks the right tool by name.
//! Called from [`crate::openhuman::agent::harness::session::builder`] at
//! agent-build time, with the orchestrator's own definition, the global
//! registry (for delegation target lookups), and the current list of
//! connected Composio integrations.
//!
//! [`AgentDefinition::when_to_use`]: crate::openhuman::agent::harness::definition::AgentDefinition::when_to_use
//! [`SubagentEntry::AgentId`]: crate::openhuman::agent::harness::definition::SubagentEntry::AgentId
//! [`SubagentEntry::Skills`]: crate::openhuman::agent::harness::definition::SubagentEntry::Skills
use super::{ArchetypeDelegationTool, SpawnSubagentTool, Tool, ARCHETYPE_TOOLS};
use crate::openhuman::agent::harness::definition::{
AgentDefinition, AgentDefinitionRegistry, SubagentEntry,
};
use crate::openhuman::context::prompt::ConnectedIntegration;
/// Build the orchestrator's tool list: one tool per installed skill +
/// one tool per archetype. Also includes `spawn_subagent` as a fallback
/// for advanced use cases (fork mode, custom agent_ids).
use super::{ArchetypeDelegationTool, SkillDelegationTool, Tool};
/// Synthesise the delegation tool list for an agent based on its
/// declarative `subagents` field.
///
/// Call this at agent build time when the visible-tool filter is active
/// (i.e. the main agent is an orchestrator).
pub fn collect_orchestrator_tools() -> Vec<Box<dyn Tool>> {
/// Each [`SubagentEntry::AgentId`] is resolved against `registry` and
/// rendered as an [`ArchetypeDelegationTool`] whose `name()` defaults to
/// `delegate_{target.id}` (overridable via the target agent's
/// `delegate_name` field) and whose `description()` is the target's
/// `when_to_use` — so editing an agent's TOML description immediately
/// updates the tool schema the orchestrator LLM sees, with zero drift.
///
/// Each [`SubagentEntry::Skills`] wildcard expands to one
/// [`SkillDelegationTool`] per connected Composio integration in
/// `connected_integrations`. The synthesised tool routes to the generic
/// `skills_agent` with `skill_filter = Some("{toolkit_slug}")` pre-set.
///
/// Entries that reference unknown agent ids (not in the registry) are
/// logged at `warn` and skipped — the orchestrator still builds, just
/// without the broken delegation. Entries that reference Skills wildcards
/// with an empty `connected_integrations` slice produce zero tools, which
/// is the correct behaviour when the user has not yet connected any
/// integrations (the LLM should not see phantom `delegate_gmail` tools
/// for unconnected toolkits).
///
/// Returns an empty Vec when `definition.subagents` is empty — callers
/// (notably the builder) handle this by not extending the visible-tool
/// set, so non-delegating agents behave identically to how they did
/// before this module existed.
pub fn collect_orchestrator_tools(
definition: &AgentDefinition,
registry: &AgentDefinitionRegistry,
connected_integrations: &[ConnectedIntegration],
) -> Vec<Box<dyn Tool>> {
let mut tools: Vec<Box<dyn Tool>> = Vec::new();
// ── Archetype-based tools (static) ────────────────────────────────
for (tool_name, agent_id, description) in ARCHETYPE_TOOLS {
log::info!(
"[orchestrator_tools] registering archetype delegation tool: {} -> {}",
tool_name,
agent_id
);
tools.push(Box::new(ArchetypeDelegationTool {
tool_name: tool_name.to_string(),
agent_id: agent_id.to_string(),
tool_description: description.to_string(),
}));
for entry in &definition.subagents {
match entry {
SubagentEntry::AgentId(agent_id) => {
let Some(target) = registry.get(agent_id) else {
log::warn!(
"[orchestrator_tools] subagent '{}' referenced by '{}' is not in the registry — skipping",
agent_id,
definition.id
);
continue;
};
let tool_name = target
.delegate_name
.clone()
.unwrap_or_else(|| format!("delegate_{}", target.id));
log::debug!(
"[orchestrator_tools] registering archetype delegation tool: {} -> {}",
tool_name,
target.id
);
tools.push(Box::new(ArchetypeDelegationTool {
tool_name,
agent_id: target.id.clone(),
tool_description: target.when_to_use.clone(),
}));
}
SubagentEntry::Skills(wildcard) => {
if !wildcard.matches_all() {
log::warn!(
"[orchestrator_tools] subagent skills wildcard '{}' referenced by '{}' is not supported (only \"*\") — skipping",
wildcard.skills,
definition.id
);
continue;
}
for integration in connected_integrations {
// Slug the toolkit name into a tool-name-safe form.
// Composio toolkit slugs are already lowercase / dash-
// separated (e.g. "gmail", "google_calendar"), but
// we guard against surprises so a quirky slug can
// never produce an invalid function-calling schema.
let slug = sanitise_slug(&integration.toolkit);
let tool_name = format!("delegate_{}", slug);
// Prefer the toolkit's own one-line description when
// available; fall back to a generic template so the
// LLM still gets a meaningful tool description even
// on brand-new or poorly-populated toolkits.
let description = if integration.description.trim().is_empty() {
format!(
"Delegate to the skills agent with the `{}` integration pre-selected.",
integration.toolkit
)
} else {
format!(
"Delegate to the skills agent using `{}`. {}",
integration.toolkit, integration.description
)
};
log::debug!(
"[orchestrator_tools] registering skill delegation tool: {} -> skills_agent (skill_filter={})",
tool_name,
slug
);
tools.push(Box::new(SkillDelegationTool {
tool_name,
skill_id: slug,
tool_description: description,
}));
}
}
}
}
// ── spawn_subagent as fallback for advanced use ────────────────────
tools.push(Box::new(SpawnSubagentTool::new()));
log::info!(
"[orchestrator_tools] total orchestrator tools: {}",
tools.len()
"[orchestrator_tools] assembled {} delegation tool(s) for agent '{}' ({} integrations connected)",
tools.len(),
definition.id,
connected_integrations.len()
);
tools
}
/// Produce a tool-name-safe slug from a free-form integration id.
/// Allows ASCII alphanumerics and underscores; everything else becomes
/// an underscore. OpenAI-style function names only accept
/// `[a-zA-Z0-9_-]{1,64}`, so this is the conservative subset.
fn sanitise_slug(raw: &str) -> String {
raw.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '_' {
c.to_ascii_lowercase()
} else {
'_'
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::openhuman::agent::harness::definition::{
DefinitionSource, ModelSpec, PromptSource, SandboxMode, SkillsWildcard, ToolScope,
};
fn def(id: &str, when_to_use: &str, delegate_name: Option<&str>) -> AgentDefinition {
AgentDefinition {
id: id.into(),
when_to_use: when_to_use.into(),
display_name: None,
system_prompt: PromptSource::Inline(String::new()),
omit_identity: true,
omit_memory_context: true,
omit_safety_preamble: true,
omit_skills_catalog: true,
model: ModelSpec::Inherit,
temperature: 0.4,
tools: ToolScope::Wildcard,
disallowed_tools: vec![],
skill_filter: None,
category_filter: None,
max_iterations: 8,
timeout_secs: None,
sandbox_mode: SandboxMode::None,
background: false,
uses_fork_context: false,
subagents: vec![],
delegate_name: delegate_name.map(String::from),
source: DefinitionSource::Builtin,
}
}
/// A real orchestrator definition that delegates to two named agents
/// (one with an explicit `delegate_name`, one without) plus a skills
/// wildcard. Exercises every branch of `collect_orchestrator_tools`.
fn sample_orchestrator() -> AgentDefinition {
let mut orch = def("orchestrator", "Routes work to the right specialist", None);
orch.subagents = vec![
SubagentEntry::AgentId("researcher".into()),
SubagentEntry::AgentId("archivist".into()),
SubagentEntry::Skills(SkillsWildcard { skills: "*".into() }),
];
orch
}
fn registry_with_targets() -> AgentDefinitionRegistry {
let mut reg = AgentDefinitionRegistry::default();
reg.insert(def(
"researcher",
"Web & docs crawler — reads real documentation",
Some("research"),
));
// `archivist` has no `delegate_name` override — tool name should
// fall back to `delegate_archivist`.
reg.insert(def(
"archivist",
"Background librarian — extracts lessons from a completed session",
None,
));
reg
}
fn integration(toolkit: &str, description: &str) -> ConnectedIntegration {
ConnectedIntegration {
toolkit: toolkit.into(),
description: description.into(),
tools: vec![],
}
}
/// Baseline: an orchestrator with 2 AgentId entries + a Skills
/// wildcard, against a registry that knows both targets and a
/// connected_integrations list with three toolkits, should produce
/// 2 + 3 = 5 delegation tools, each with the expected name and
/// description source.
#[test]
fn collects_agentid_entries_and_expands_skills_wildcard() {
let orch = sample_orchestrator();
let reg = registry_with_targets();
let integrations = vec![
integration("gmail", "Send and read email via Gmail."),
integration("github", "Manage repos, issues, and pull requests."),
integration("notion", "Read and write pages and databases."),
];
let tools = collect_orchestrator_tools(&orch, &reg, &integrations);
let names: Vec<&str> = tools.iter().map(|t| t.name()).collect();
assert_eq!(
names,
vec![
"research", // researcher's delegate_name override
"delegate_archivist", // archivist has no delegate_name → default
"delegate_gmail",
"delegate_github",
"delegate_notion",
],
"tool names should come from delegate_name overrides, id fallbacks, and sanitised toolkit slugs"
);
// Descriptions should come from when_to_use for archetype tools,
// and from a templated string mentioning the toolkit display name
// for skill tools.
let research_tool = tools.iter().find(|t| t.name() == "research").unwrap();
assert!(research_tool.description().contains("crawler"));
let gmail_tool = tools.iter().find(|t| t.name() == "delegate_gmail").unwrap();
assert!(gmail_tool.description().contains("gmail"));
assert!(gmail_tool.description().contains("email"));
}
/// An orchestrator with a Skills wildcard but no connected
/// integrations should produce zero skill delegation tools — the LLM
/// must not be shown phantom `delegate_*` tools for toolkits that
/// aren't authorised.
#[test]
fn skills_wildcard_with_no_integrations_produces_no_tools() {
let orch = sample_orchestrator();
let reg = registry_with_targets();
let tools = collect_orchestrator_tools(&orch, &reg, &[]);
let names: Vec<&str> = tools.iter().map(|t| t.name()).collect();
assert_eq!(names, vec!["research", "delegate_archivist"]);
}
/// An AgentId entry that points at an id not present in the registry
/// should be logged and silently skipped, rather than panicking or
/// aborting tool assembly. The orchestrator still builds.
#[test]
fn unknown_subagent_id_is_skipped_not_fatal() {
let mut orch = def("orchestrator", "test", None);
orch.subagents = vec![
SubagentEntry::AgentId("researcher".into()),
SubagentEntry::AgentId("ghost_agent_nope".into()),
];
let reg = registry_with_targets();
let tools = collect_orchestrator_tools(&orch, &reg, &[]);
let names: Vec<&str> = tools.iter().map(|t| t.name()).collect();
assert_eq!(names, vec!["research"]);
}
/// An empty `subagents` list should produce zero tools — regular
/// non-delegating agents (welcome, code_executor, etc.) reach this
/// path without any subagents and must not pick up stray tools.
#[test]
fn empty_subagents_produces_no_tools() {
let orch = def("welcome", "First agent", None);
let reg = registry_with_targets();
let tools = collect_orchestrator_tools(&orch, &reg, &[]);
assert!(tools.is_empty());
}
/// Toolkit slugs with dashes, spaces, or mixed case should be
/// normalised to `[a-z0-9_]` before being used as part of a function
/// name — the OpenAI tool-calling schema has strict character rules.
#[test]
fn sanitise_slug_lowercases_and_replaces_invalid_chars() {
assert_eq!(sanitise_slug("Gmail"), "gmail");
assert_eq!(sanitise_slug("google-calendar"), "google_calendar");
assert_eq!(sanitise_slug("slack.bot"), "slack_bot");
assert_eq!(sanitise_slug("weird name!"), "weird_name_");
}
}
+7
View File
@@ -552,10 +552,16 @@ fn extract_string_outcome(result: &Value) -> String {
}
fn write_min_config(openhuman_dir: &Path, api_origin: &str) {
// `chat_onboarding_completed = true` bypasses the welcome agent so that
// `channel_web_chat` in tests routes straight to the orchestrator. Without
// this, the first chat turn goes through the welcome flow whose tool
// contract is not modelled by the e2e mock, which closes the SSE stream
// mid-response.
let cfg = format!(
r#"api_url = "{api_origin}"
default_model = "e2e-mock-model"
default_temperature = 0.7
chat_onboarding_completed = true
[secrets]
encrypt = false
@@ -589,6 +595,7 @@ fn write_min_config_with_local_ai_disabled(openhuman_dir: &Path, api_origin: &st
r#"api_url = "{api_origin}"
default_model = "e2e-mock-model"
default_temperature = 0.7
chat_onboarding_completed = true
[secrets]
encrypt = false