fix(agent): thread agent_id into build_session_agent_inner#584
Conversation
build_session_agent_inner took an agent_id: &str parameter but never threaded it into the Agent::builder() chain — a `let _ = agent_id;` silenced the unused-variable warning. AgentBuilder::build() fell back to the legacy "main" default at builder.rs:310-312, so every session built via Agent::from_config_for_agent carried agent_definition_name="main" regardless of the id the caller asked for. Only two ids reach this path in production: "orchestrator" (legacy Agent::from_config wrapper, cron, local_ai, escalation) and "welcome" (channels/providers/web.rs routing after tinyhumansai#525/tinyhumansai#526, and welcome_proactive). Orchestrator is benign — "main" was already an alias for orchestrator everywhere downstream (see debug_dump.rs:249). Welcome is the visible bug — agent_definition_name feeds three surfaces that are all silently wrong pre-fix: 1. Transcript filename — welcome sessions land as main_*.md instead of welcome_*.md. 2. Transcript metadata — the <!-- session_transcript --> block stamps `agent: main` instead of `agent: welcome`. 3. Transcript resume cross-contamination — try_load_session_transcript calls find_latest_transcript(workspace_dir, "main") which scans all dated session dirs for the latest main_*.md. It finds the last orchestrator session and resumes from it, loading its system prompt + user messages + assistant tool-call history into the welcome agent's `history` as a resume prefix before run_single runs. The written transcript mixes yesterday's orchestrator email thread with today's welcome message under the same main_0.md filename. Reproduced live 2026-04-16: a welcome_proactive session loaded 5 messages from yesterday's 15042026/main_0.md and wrote 6 messages back that included a spawn_subagent(skills_agent, toolkit=gmail) tool-call the welcome agent never made. Prompt rendering is NOT affected. context/prompt.rs only reads ctx.agent_id for the is_skill_executor branch at prompt.rs:655, so welcome/main/orchestrator all fall through the same delegator path. The welcome persona prompt is rendered by the stamped SystemPromptBuilder::for_subagent(target_def.system_prompt) built at session-build time, independent of agent_definition_name. The pre-fix main_0.md on disk contains `# Welcome Agent\n\nYou are the Welcome agent...` as its system prompt body — correct content, wrong filename and metadata. skills_agent and other typed sub-agents are unaffected — they go through subagent_runner, which constructs its prompt directly and writes transcripts under the explicit id it receives. The pre-existing sessions/15042026/skills_agent_0.md on disk with `agent: skills_agent` confirms this code path has always been correct. Changes: * src/openhuman/agent/harness/session/builder.rs: - Add .agent_definition_name(agent_id.to_string()) to the builder chain in build_session_agent_inner. - Delete the `let _ = agent_id;` suppression. - Replace the misleading 8-line comment block at the call site (which claimed event_channel was for transport identity and therefore stamping agent_id wasn't needed — conflating two unrelated fields) with an accurate description of the three load-bearing surfaces. - Expand the docstring on AgentBuilder::agent_definition_name to document the surfaces and the latent prompt-section foot-gun the fix closes for future code. - New log::debug! call at the stamping site for grep-friendly runtime traces ([agent::builder] stamping agent_definition_name=<id> onto session agent). * src/openhuman/agent/harness/session/runtime.rs: - Add pub fn agent_definition_name(&self) -> &str accessor on Agent so tests and runtime callers can introspect the stamped id without reaching into the pub(super) field. * src/openhuman/agent/harness/session/tests.rs: - Add build_minimal_agent_with_definition_name helper. - agent_builder_threads_agent_definition_name_when_set — parameterised over welcome/skills_agent/orchestrator/ trigger_triage, asserts the setter threads the id through. - agent_builder_falls_back_to_main_when_definition_name_unset — pins the legacy fallback contract direct builder users rely on. Tests: 2 passed; 0 failed; 3477 filtered out; finished in 0.21s. cargo check --lib clean; cargo fmt clean. Verified live against G:/projects/vezures/.openhuman on 2026-04-16: - Pre-fix welcome_proactive run wrote sessions/16042026/main_0.md with `agent: main` in the metadata header. - Post-fix welcome_proactive run wrote sessions/16042026/welcome_0.md with `agent: welcome` in the metadata header. - Post-fix web-chat dispatch to orchestrator wrote sessions/16042026/orchestrator_0.md (moved off the historical main_*.md alias — behaviorally unchanged for orchestrator). - The new [agent::builder] stamping debug line fires on both welcome and orchestrator paths. Does not touch: subagent_runner, spawn_subagent / dispatch_subagent, any agent/agents/*/agent.toml, any context::prompt code, or the AgentBuilder::build() fallback itself. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (3)
📝 WalkthroughWalkthroughThe changes enhance agent identity management by updating Changes
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~22 minutes Poem
🚥 Pre-merge checks | ✅ 3✅ Passed checks (3 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Summary
agent_definition_namesilently falling back to"main"for everyAgentbuilt viaAgent::from_config_for_agent, becausebuild_session_agent_innerdropped itsagent_id: &strparameter via alet _ = agent_id;suppression instead of threading it into theAgent::builder()chain.orchestratorreached this path and"main"was already its historical alias, so the bug was invisible. After feat(agent): welcome->orchestrator routing + per-agent tool scoping (#525, #526) #544 landed real dispatch routing that buildswelcomeviafrom_config_for_agent("welcome", ...), welcome sessions became the first production caller to pass a non-orchestrator id through this code, and the bug became observable on disk.sessions/DDMMYYYY/main_*.mdinstead ofwelcome_*.md), wrong metadata header (agent: main), and — most seriously — transcript resume cross-contamination wherefind_latest_transcript("main")loads the most recent orchestrator session's history into the welcome agent'shistoryas a resume prefix beforerun_single.context/prompt.rsonly branches onctx.agent_idfor oneis_skill_executorcheck atprompt.rs:655).skills_agentand the other typed sub-agents are NOT affected either (they go throughsubagent_runner, which bypasses this builder).yarn tauri devagainst a fresh workspace: pre-fix writessessions/16042026/main_0.mdwithagent: mainfor a welcome session; post-fix writeswelcome_0.mdwithagent: welcome.Problem
Agent::from_config_for_agent(config, agent_id)resolves the targetAgentDefinitionfrom the registry and hands off tobuild_session_agent_inner(config, agent_id, target_def). That inner function takesagent_id: &stras a parameter but never threads it into theAgent::builder()chain. Alet _ = agent_id;was silencing the unused-variable warning, and an 8-line comment block at the call site rationalised the omission by claiming "event_channel is for transport identity, not the agent-definition id" — conflating two unrelated fields (event_channelandagent_definition_name).When
AgentBuilder::build()runs, the missing field falls back to the legacy default atbuilder.rs:310-312:Every session built via
from_config_for_agentcame out stamped"main"at runtime, regardless of which id the caller asked for.Scope — which ids actually reach this path
A grep of all callers of
Agent::from_config_for_agentandAgent::from_configacross the repo returns exactly two agent ids in production:agent/triage/escalation.rs:130"orchestrator"(viaAgent::from_configlegacy wrapper)cron/scheduler.rs:197"orchestrator"(viaAgent::from_config)local_ai/ops.rs:41"orchestrator"(viaAgent::from_config)channels/providers/web.rs:826"orchestrator"OR"welcome"(viapick_target_agent_id— the #525/#526 routing)agent/welcome_proactive.rs:94"welcome"(hardcoded, fired fromset_onboarding_completed(true))No caller passes
"skills_agent","researcher","planner","code_executor","critic","archivist","tool_maker","trigger_triage","trigger_reactor","morning_briefing", or"fork". Those agent ids are spawned exclusively viaagent/harness/subagent_runner.rs::run_subagent, which constructs its own system prompt viarender_subagent_system_promptand writes transcripts under the explicit id it is handed. The pre-existingsessions/15042026/skills_agent_0.mdon disk withagent: skills_agentstamped in its metadata header confirms the subagent_runner path has always been correct.Why this was latent
The legacy
.unwrap_or_else(|| "main".to_string())default was designed for a world where the only top-level agent was the orchestrator, and the orchestrator was called"main"in logs and file paths. You can see that assumption baked intocontext/debug_dump.rs:249:Every downstream consumer already treats
"main"as an orchestrator alias, which made the bug benign for the orchestrator path — the field's value was wrong but the downstream effect was zero.#525 / #526 (PR #544) added real dispatch routing that builds the welcome agent via
from_config_for_agent("welcome", ...)pre-onboarding. That was the first production caller to pass a non-orchestrator id through this code path, and it latently activated the bug.User-visible impact — three surfaces, all silently wrong pre-fix for welcome
1. Session transcript filename
transcript::resolve_new_transcript_path(workspace_dir, agent_name)attranscript.rs:166usesagent_nameas the{agent}prefix insessions/DDMMYYYY/{agent}_{index}.md:sessions/16042026/main_0.mdsessions/16042026/welcome_0.md2. Transcript metadata header
transcript::write_transcriptattranscript.rs:94stamps theagent:line inside the<!-- session_transcript -->block frommeta.agent_name, populated fromself.agent_definition_nameinturn.rs:1140:3. Transcript resume cross-contamination — the nastiest one
Agent::try_load_session_transcriptatturn.rs:1061callstranscript::find_latest_transcript(workspace_dir, self.agent_definition_name)on every session start. That function scans all dated session dirs for{agent_name}_*.mdand returns the most recent one.Pre-fix, a welcome session stamped
"main"asks for the latestmain_*.mdacross all dated dirs and finds the most recent orchestrator session's transcript (which is also filed undermain_*.mdbecause of the historical alias). It loads its 5 messages — system prompt, user message, assistant tool-call history, tool result — into the welcome agent'shistoryas a resume prefix before the welcome agent'srun_singlecall. The welcome agent then runs its inference on top of that cross-contaminated history.This was reproduced live on 2026-04-16 against
G:/projects/vezures/.openhuman:Inspecting the written
sessions/16042026/main_0.mdafterwards showed a mix of yesterday's orchestrator email thread (send an email to alan@mahadao.com say hi from openhuman→spawn_subagent(agent_id="skills_agent", toolkit="gmail", ...)→ tool resultEmail sent successfully!) with today's welcome agent's response appended on top. The welcome agent's LLM call had been fed an unrelated orchestrator session as context.What is NOT affected
Prompt rendering. A grep across the entire
context::promptmodule finds exactly one reader ofctx.agent_id:That is the only branch. Welcome / main / orchestrator all fall through the same delegator path regardless of
ctx.agent_id. The welcome agent's persona prompt comes from aSystemPromptBuilder::for_subagent(target_def.system_prompt)constructed at session-build time and stamped into theAgentas.prompt_builder(prompt_builder)— that builder renders welcome-correct bytes independently ofagent_definition_name. Verified on disk: the clean pre-fixmain_0.mdopens with# Welcome Agent\n\nYou are the **Welcome** agent..., confirming the system prompt body was welcome-flavored even though the filename and metadata were wrong.Solution
One functional line added in
build_session_agent_inner:Plus supporting scaffolding:
let _ = agent_id;suppression.event_channelwithagent_definition_name) with an accurate description of the three downstream surfaces and the concrete pre-fix manifestation.AgentBuilder::agent_definition_nameto document all three surfaces and note the latent prompt-section foot-gun the fix closes for future code (any future prompt section that branches on a non-skills_agentid would silently never fire if this field stayed at"main").pub fn agent_definition_name(&self) -> &straccessor onAgentinruntime.rs, for runtime introspection and test assertions.log::debug!call at the stamping site so the fix is greppable in runtime logs:[agent::builder] stamping agent_definition_name=<id> onto session agent.session/tests.rs:agent_builder_threads_agent_definition_name_when_set— parameterised over["welcome", "skills_agent", "orchestrator", "trigger_triage"], asserts.agent_definition_name(id).build()produces anAgentwhose accessor returns that id verbatim.welcomeandorchestratorare the two ids that reachfrom_config_for_agentin production today;skills_agentandtrigger_triageare defensive coverage in case a future commit adds a new top-level caller.agent_builder_falls_back_to_main_when_definition_name_unset— builds via a minimal helper without the setter, asserts the legacy"main"fallback still fires (so direct builder users in tests / CLI still work).Both tests pass:
2 passed; 0 failed; 0 ignored; 3477 filtered out; finished in 0.21s.The fix does NOT touch:
subagent_runner.rsor any sub-agent spawn pathagent/agents/*/agent.tomldefinitionspawn_subagent/dispatch_subagenttool implementationcontext::promptAgentBuilder::build()fallback itself (preserved for direct-builder callers)Submission Checklist
src/openhuman/agent/harness/session/tests.rspinning the builder contract. Both green.yarn tauri devagainstOPENHUMAN_WORKSPACE=G:/projects/vezures/.openhuman:sessions/16042026/main_0.mdwithagent: mainin the metadata. On an earlier attempt with existingmain_*.mdtranscripts on disk, the welcome session also demonstrated the resume cross-contamination path, loading 5 messages from yesterday's orchestrator transcript as the welcome agent's prefix.sessions/16042026/welcome_0.mdwithagent: welcome. The subsequent web-chat turn routed throughpick_target_agent_idto orchestrator, which wrotesessions/16042026/orchestrator_0.mdwithagent: orchestrator. The new debug log[agent::builder] stamping agent_definition_name=<id> onto session agentfired on both turns.AgentBuilder::agent_definition_namerewritten; accessor docstring onAgent::agent_definition_nameadded; test docstrings describe the bug surfaces accurately; call-site comment block replaced with a correct narrative.log::debug!call at the stamping site gives the fix a grep-friendly runtime trace.Impact
main_*.mdtoorchestrator_*.mdfilenames. Behaviorally identical everywhere downstream because"main"was already an orchestrator alias.main_*.mdtowelcome_*.mdfilenames withagent: welcomemetadata, and no longer cross-contaminate on resume.Agent::try_load_session_transcriptlooks up by the new correctagent_definition_name. A freshly-patched orchestrator session will not discover existingmain_*.mdfiles on resume — it starts cold on the first turn after the upgrade. Welcome's cold start is the correct behavior.Stringallocation per session construction — negligible on a path that only runs at session start.mainin forensics) and closes the cross-contamination path, which was loading orchestrator tool-call history (tool arguments and results) into welcome agent LLM context unintentionally."main"fallback inAgentBuilder::build()is preserved; onlybuild_session_agent_innernow overrides it. Direct builder users (tests, CLI, custom agents) that never call the setter still get the"main"default.Related
welcome_proactivepublishes its generated message to socket.io asevent=proactive_message room=system thread_id=proactive:welcome, but the React app has no subscriber for that event — it is logged via anonAnycatch-all insocketService.ts:182and dropped. The welcome agent's message is never rendered anywhere user-facing. Out of scope for this PR; should be filed as a separate frontend issue."main"alias for orchestrator by updatingdebug_dump.rs:249, theAgent::from_configlegacy wrapper, and any other"main" | "orchestrator"disjunctions to use"orchestrator"directly. Would eliminate the historical alias and the foot-gun it creates. Out of scope for this PR.🤖 Generated with Claude Code
Summary by CodeRabbit
Bug Fixes
Tests