Skip to content

Commit 19aa50a

Browse files
sanil-23claude
andauthored
fix(agent): thread agent_id into build_session_agent_inner (tinyhumansai#584)
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>
1 parent d4f5b9a commit 19aa50a

3 files changed

Lines changed: 180 additions & 11 deletions

File tree

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

Lines changed: 64 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -186,9 +186,43 @@ impl AgentBuilder {
186186
self
187187
}
188188

189-
/// Sets the human-readable agent definition name used as the
190-
/// `{agent}` prefix in session transcript filenames
191-
/// (`sessions/DDMMYYYY/{agent}_{index}.md`).
189+
/// Sets the agent definition id this session is running
190+
/// (`welcome`, `orchestrator`, `skills_agent`, …).
191+
///
192+
/// This value is stamped onto the built [`Agent`] and surfaces in
193+
/// the following places:
194+
///
195+
/// * **Transcript filename on disk** — `transcript::write_transcript`
196+
/// and `transcript::find_latest_transcript` use it as the
197+
/// `{agent}` prefix in `sessions/DDMMYYYY/{agent}_{index}.md`.
198+
/// Both the write path and the resume-lookup path read the same
199+
/// field on `self`, so a session is always self-consistent; the
200+
/// user-visible signal is which filename the transcript lands
201+
/// under. Leaving it at the legacy `"main"` fallback silently
202+
/// misfiles every non-orchestrator session under `main_*.md`.
203+
/// * **Transcript metadata header** — `transcript::write_transcript`
204+
/// stamps it into the `<!-- session_transcript\nagent: {name}\n… -->`
205+
/// block at the top of every `.md` file. This is the ground-truth
206+
/// signal for "which agent definition ran this session" when
207+
/// inspecting transcripts after the fact.
208+
/// * **[`PromptContext::agent_id`]** at prompt-build time (see
209+
/// `turn.rs`). Today only one prompt section reads this field —
210+
/// the `Connected Integrations` branch in `context/prompt.rs`
211+
/// that special-cases `skills_agent` vs every other agent — so
212+
/// the current user-visible impact of a wrong id is limited to
213+
/// the two bullets above. The stamped `prompt_builder` injected
214+
/// by [`Agent::from_config_for_agent`] is what actually drives
215+
/// prompt flavour per archetype, independent of this field. That
216+
/// said, any future prompt section that branches on a
217+
/// non-`skills_agent` id (e.g. welcome-specific banner, planner-
218+
/// specific rubric) would silently never fire if the field were
219+
/// left at `"main"`, so keeping it correctly stamped closes a
220+
/// latent foot-gun for code that hasn't been written yet.
221+
///
222+
/// Callers building via [`Agent::from_config_for_agent`] get this
223+
/// wired automatically inside `build_session_agent_inner`; direct
224+
/// builder users (tests, CLI) must set it explicitly if they care
225+
/// about any of the surfaces above.
192226
pub fn agent_definition_name(mut self, name: impl Into<String>) -> Self {
193227
self.agent_definition_name = Some(name.into());
194228
self
@@ -789,14 +823,32 @@ impl Agent {
789823
let effective_omit_profile = target_def.map(|def| def.omit_profile).unwrap_or(true);
790824
let effective_omit_memory_md = target_def.map(|def| def.omit_memory_md).unwrap_or(true);
791825

792-
// `agent_id` is not stamped onto the returned Agent here — the
793-
// `event_channel` field on `Agent` is for transport identity
794-
// (which channel the session belongs to: "web_channel", "cli",
795-
// etc.), not the agent-definition id. Callers that want to
796-
// record which definition they asked for should log it at
797-
// call-site. The `[agent::builder]` info trace at the top of
798-
// `from_config_for_agent` already captures this.
799-
let _ = agent_id; // silence unused-warning if all code paths above move
826+
// Stamp the resolved agent definition id onto the Agent via the
827+
// builder. Without this call, `agent_definition_name` falls
828+
// back to the legacy `"main"` default (see `AgentBuilder::build`)
829+
// for every non-orchestrator caller. In the current codebase
830+
// that is benign for the orchestrator (which is already aliased
831+
// as `"main"` everywhere downstream) but causes two concrete
832+
// bugs for the welcome agent, which is the only other id that
833+
// reaches this function in practice:
834+
//
835+
// 1. Its session transcripts are misfiled on disk under
836+
// `sessions/DDMMYYYY/main_*.md` instead of `welcome_*.md`.
837+
// 2. The `agent:` line inside each transcript's metadata
838+
// header stamps `agent: main` instead of `agent: welcome`.
839+
//
840+
// Skills_agent and every other typed sub-agent are unaffected
841+
// because they never build via `from_config_for_agent` — they
842+
// are spawned through `subagent_runner` which constructs its
843+
// prompt and history directly.
844+
//
845+
// See the docstring on `AgentBuilder::agent_definition_name`
846+
// for the full list of surfaces and the latent prompt-section
847+
// foot-gun this call also closes.
848+
log::debug!(
849+
"[agent::builder] stamping agent_definition_name={} onto session agent",
850+
agent_id
851+
);
800852

801853
Agent::builder()
802854
.provider(provider)
@@ -818,6 +870,7 @@ impl Agent {
818870
.auto_save(config.memory.auto_save)
819871
.post_turn_hooks(post_turn_hooks)
820872
.learning_enabled(config.learning.enabled)
873+
.agent_definition_name(agent_id.to_string())
821874
.omit_profile(effective_omit_profile)
822875
.omit_memory_md(effective_omit_memory_md)
823876
.build()

src/openhuman/agent/harness/session/runtime.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,20 @@ impl Agent {
3333
&self.event_channel
3434
}
3535

36+
/// The agent definition id this session is running
37+
/// (`"welcome"`, `"orchestrator"`, `"skills_agent"`, …).
38+
///
39+
/// Exposed so callers that build sessions via
40+
/// [`Agent::from_config_for_agent`] can stamp the resolved id onto
41+
/// correlation logs and progress events without reaching for the
42+
/// source `Config`. See [`AgentBuilder::agent_definition_name`]
43+
/// for the full list of downstream surfaces (transcript filename,
44+
/// transcript metadata header, and `PromptContext::agent_id`) that
45+
/// read this field.
46+
pub fn agent_definition_name(&self) -> &str {
47+
&self.agent_definition_name
48+
}
49+
3650
/// Returns a new `AgentBuilder`.
3751
pub fn builder() -> AgentBuilder {
3852
AgentBuilder::new()

src/openhuman/agent/harness/session/tests.rs

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,108 @@ fn _assert_builder_is_exported() -> AgentBuilder {
138138
Agent::builder()
139139
}
140140

141+
/// Minimal in-memory `Agent` build that every agent_definition_name
142+
/// regression test reuses. Spins up a scratch workspace, a `none`
143+
/// memory backend, a one-response `MockProvider`, and a single
144+
/// `MockTool`, then feeds those into [`Agent::builder`]. Returns the
145+
/// built `Agent` so individual tests can assert against the
146+
/// [`Agent::agent_definition_name`] accessor.
147+
fn build_minimal_agent_with_definition_name(definition_name: Option<&str>) -> Agent {
148+
let workspace = tempfile::TempDir::new().expect("temp workspace");
149+
let workspace_path = workspace.path().to_path_buf();
150+
151+
let provider = Box::new(MockProvider {
152+
responses: Mutex::new(vec![]),
153+
});
154+
155+
let memory_cfg = crate::openhuman::config::MemoryConfig {
156+
backend: "none".into(),
157+
..crate::openhuman::config::MemoryConfig::default()
158+
};
159+
let mem: Arc<dyn Memory> = Arc::from(
160+
crate::openhuman::memory::create_memory(&memory_cfg, &workspace_path, None).unwrap(),
161+
);
162+
163+
let mut builder = Agent::builder()
164+
.provider(provider)
165+
.tools(vec![Box::new(MockTool)])
166+
.memory(mem)
167+
.tool_dispatcher(Box::new(NativeToolDispatcher))
168+
.workspace_dir(workspace_path);
169+
170+
if let Some(name) = definition_name {
171+
builder = builder.agent_definition_name(name);
172+
}
173+
174+
builder.build().expect("minimal agent build should succeed")
175+
}
176+
177+
/// Regression test for the `build_session_agent_inner` agent-id
178+
/// threading bug.
179+
///
180+
/// Prior to the fix, `build_session_agent_inner` took an `agent_id:
181+
/// &str` parameter but never threaded it into the `Agent::builder()`
182+
/// chain. The builder's `.build()` then fell back to the legacy
183+
/// `"main"` default, and every session built via
184+
/// `Agent::from_config_for_agent` carried `agent_definition_name =
185+
/// "main"` at runtime regardless of which id the caller asked for.
186+
///
187+
/// In the current codebase only two ids actually reach
188+
/// `from_config_for_agent` in production: `"orchestrator"` (via the
189+
/// `Agent::from_config` legacy wrapper and the post-onboarding web
190+
/// dispatch path) and `"welcome"` (via `welcome_proactive` and the
191+
/// pre-onboarding web dispatch path). The orchestrator case is
192+
/// benign — `"main"` is already an alias for orchestrator everywhere
193+
/// downstream, so the behavior is a no-op. The welcome case is the
194+
/// one the user sees: welcome sessions were being misfiled on disk
195+
/// as `sessions/DDMMYYYY/main_*.md` instead of `welcome_*.md`, and
196+
/// the `agent:` line inside each transcript's `<!-- session_transcript
197+
/// -->` metadata header stamped `agent: main` instead of
198+
/// `agent: welcome`. Skills_agent and the other typed sub-agents are
199+
/// unaffected because they're spawned through `subagent_runner` and
200+
/// never touch the `from_config_for_agent` / builder fallback path.
201+
///
202+
/// This test pins the builder contract the fix relies on: calling
203+
/// `.agent_definition_name(id)` on the builder chain produces an
204+
/// `Agent` whose [`Agent::agent_definition_name`] accessor returns
205+
/// that id verbatim. `"welcome"` and `"orchestrator"` exercise the
206+
/// two ids that reach `from_config_for_agent` today; `"skills_agent"`
207+
/// and `"trigger_triage"` are defensive coverage so that if a
208+
/// future commit adds a new top-level caller for one of those ids
209+
/// the builder contract is already pinned.
210+
#[test]
211+
fn agent_builder_threads_agent_definition_name_when_set() {
212+
for expected in ["welcome", "skills_agent", "orchestrator", "trigger_triage"] {
213+
let agent = build_minimal_agent_with_definition_name(Some(expected));
214+
assert_eq!(
215+
agent.agent_definition_name(),
216+
expected,
217+
"agent.agent_definition_name() should return the value passed to the builder"
218+
);
219+
}
220+
}
221+
222+
/// Complementary to [`agent_builder_threads_agent_definition_name_when_set`]:
223+
/// when a caller builds an `Agent` without ever calling
224+
/// [`AgentBuilder::agent_definition_name`], the legacy `"main"`
225+
/// fallback still applies. This pins the fallback contract that
226+
/// direct builder users (tests, CLI harnesses) rely on, and
227+
/// documents the exact misbehaviour the threading fix prevents —
228+
/// `build_session_agent_inner` used to hit this fallback even when
229+
/// a caller asked for `welcome`, because the `.agent_definition_name`
230+
/// setter was missing from the builder chain. The result was that
231+
/// welcome sessions landed on disk as `main_*.md` with `agent: main`
232+
/// stamped into their transcript metadata header.
233+
#[test]
234+
fn agent_builder_falls_back_to_main_when_definition_name_unset() {
235+
let agent = build_minimal_agent_with_definition_name(None);
236+
assert_eq!(
237+
agent.agent_definition_name(),
238+
"main",
239+
"AgentBuilder::build should default agent_definition_name to \"main\" when unset"
240+
);
241+
}
242+
141243
#[tokio::test]
142244
async fn turn_without_tools_returns_text() {
143245
let workspace = tempfile::TempDir::new().expect("temp workspace");

0 commit comments

Comments
 (0)