Skip to content

Skip hook loading and lifecycle events for subagents - #10596

Merged
alexhancock merged 3 commits into
aaif-goose:mainfrom
dakotafabro:fix/skip-hooks-for-subagents
Aug 10, 2026
Merged

Skip hook loading and lifecycle events for subagents#10596
alexhancock merged 3 commits into
aaif-goose:mainfrom
dakotafabro:fix/skip-hooks-for-subagents

Conversation

@dakotafabro

@dakotafabro dakotafabro commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Problem

When goose delegates a task to a subagent, the subagent creates a fresh Agent instance that loads the full hook lifecycle - discovering plugins, parsing hooks.json, compiling matchers, and firing SessionStart. This means every delegate triggers events meant for user-facing sessions: banner scripts execute mid-conversation, session-tracking plugins log phantom sessions, and any plugin subscribing to lifecycle events receives signals that violate the hook contract.

How this was discovered

While building goose-banner - a plugin that replaces the default "goose is ready" message with useful session context (active threads, token spend, project state) - the banner scripts were firing every time a delegate spun up. This surfaced the broader issue: the hook system's SessionStart event doesn't actually mean "a user started a session" - it fires for subagents too, which breaks the contract plugins rely on.

Benefit

Hook contract correctness. SessionStart fires exactly once per user-facing session. Subagents no longer trigger lifecycle events. Plugin authors can trust the signals they receive without building their own "am I a subagent?" guards.

Ecosystem scalability. As the plugin ecosystem grows (permission gates, audit logging, compliance checks, session tracking), every new plugin multiplies the per-delegate overhead today. After this fix, the cost of adding plugins stays zero for subagents permanently.

No more mid-session noise. Banner output, session counters, and state initialization no longer fire during delegates. The terminal stays clean. Plugins that track session state don't log phantom sessions.

Foundation for plugin trust. Plugins like goose-banner and spore (retrieval tracking, interoception) rely on SessionStart to initialize per-session state. Without this fix, subagent-triggered SessionStart overwrites the parent session's state mid-conversation.

Fix (two mechanisms)

1. Skip hooks for subagents

Adds is_subagent: bool to AgentConfig (defaults false). When true, Agent::with_config assigns an empty HookManager::default() instead of calling HookManager::load(). Both delegate paths in summon.rs (sync and async) set is_subagent = true.

2. Prevent double-emission in the parent

Adds an AtomicBool guard (session_start_emitted) to Agent. The CLI banner path sets it via emit_hook_with_banners, and reply() uses an atomic swap to skip if already fired. Non-CLI paths (gateway, ACP server, scheduler) that only go through reply() still emit exactly once via the same guard.

What this does NOT affect

  • Extension loading (MCP servers) - completely separate system, untouched. Each subagent still spins up its own independent MCP client processes.
  • Extension cleanup - subagent destruction relies on Rust's ownership/drop semantics. Fully intact.
  • SessionEnd hooks - were never fired for subagents even before this change.
  • Provider configuration - untouched
  • Session management - untouched
  • Parent session hooks - still fire normally

Performance (real but secondary)

Per-delegate overhead eliminated:

Operation Before (per delegate) After
Plugin filesystem scan ~2-5ms 0
hooks.json parse + regex compile ~1-2ms per plugin 0
SessionStart script execution ~50-200ms (process fork + I/O) 0
Hook matcher evaluation per tool call ~0.1ms per call per plugin 0

Savings are marginal today (~50s/week for a heavy user with 3 plugins), but compound as the plugin ecosystem grows.

Commits

  1. feat: session banner hooks - adds emit_hook_with_banners to the CLI path so plugins can surface context at session startup (the feature that exposed the bug)
  2. Skip hook loading for subagents - is_subagent flag on AgentConfig, empty HookManager for delegates
  3. Prevent duplicate emission - AtomicBool guard ensures SessionStart fires at most once per Agent instance regardless of code path

@dakotafabro
dakotafabro marked this pull request as ready for review July 20, 2026 23:50

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e7b723b9de

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread crates/goose-cli/src/session/mod.rs
@dakotafabro

Copy link
Copy Markdown
Contributor Author

🤖 Addressed - added an AtomicBool guard (session_start_emitted) to Agent. The CLI banner path sets it via emit_hook_with_banners, and reply() uses an atomic swap to skip if already fired. Non-CLI paths (gateway, ACP, scheduler) that only go through reply() still emit exactly once via the same guard. See 6e60aee.

@alexhancock alexhancock self-assigned this Jul 30, 2026
SessionStart hooks can now return {"banner":"..."} on stdout to
contribute lines to the CLI startup banner. Multiple plugins can each
provide banner content, printed between 'goose is ready' and the
context usage bar.

This enables plugins like session-continuity tools, project dashboards,
and reminder systems to surface information at the moment it's most
useful - before the user types their first prompt.

Backwards compatible: hooks that don't output JSON or lack a banner
field behave exactly as before.
Subagents were creating a full HookManager on every delegate spawn,
discovering all enabled plugins, parsing hooks.json files, compiling
regex matchers, and then firing SessionStart (which executed user
scripts like banner generators mid-session).

This adds is_subagent to AgentConfig (defaults false) and assigns an
empty HookManager when true, eliminating plugin discovery overhead
and preventing spurious lifecycle events during delegation.
When the CLI calls emit_hook_with_banners(SessionStart) for banner
collection, mark the event as already emitted so reply() does not
fire it again on the first agent turn. Non-CLI paths (gateway, ACP
server, scheduler) that go directly through reply() still emit it
once via the atomic swap guard.
@dakotafabro
dakotafabro force-pushed the fix/skip-hooks-for-subagents branch from 6e60aee to 7781486 Compare August 7, 2026 22:07
@dakotafabro

Copy link
Copy Markdown
Contributor Author

🤖 Rebased onto current main. Conflict resolved (unused imports from earlier iteration removed). CI was green before, should pass again on the new base.

Ready for review whenever someone has bandwidth.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7781486fab

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +488 to +490
if event == crate::hooks::HookEvent::SessionStart {
self.session_start_emitted.store(true, Ordering::Release);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Guard SessionStart in the state-machine path

When GOOSE_STATE_MACHINE=1 is enabled for a fresh interactive session, this flag only suppresses the legacy reply emission; StateMachine::emit_entry_hooks still emits SessionStart unconditionally at crates/goose/src/agents/state_machine/machine.rs:64-70. Consequently, the hook executed for the banner in session/mod.rs runs again when the first prompt enters the state machine, repeating setup, analytics, or other side effects. Fresh evidence beyond the earlier comment is that the legacy branch now checks this atomic at agent.rs:1936, while the state-machine branch does not, so the suppression needs to be shared with and tested in both paths.

AGENTS.md reference: AGENTS.md:L21-L23

Useful? React with 👍 / 👎.

@alexhancock
alexhancock self-requested a review August 10, 2026 17:25
@alexhancock

Copy link
Copy Markdown
Collaborator

Thanks for the submission! It makes sense to me. Merging.

@alexhancock
alexhancock merged commit 3e15ccb into aaif-goose:main Aug 10, 2026
34 of 35 checks passed
michaelneale added a commit that referenced this pull request Aug 10, 2026
* origin/main:
  fix(mcp): prune dead notification subscribers (#11032)
  chore: remove the extension and tool count suggestion (#10869)
  feat: compaction in the GDK (#11042)
  fix(provider): retry transient errors on first stream item before ending turn (#10968)
  feat(cli): add /new to start a fresh session without restarting (#10767)
  feat(acp): title new sessions from _meta.sessionTitle (#10712)
  fix: adjust rmcp::model::Meta ref (#11107)
  Skip hook loading and lifecycle events for subagents (#10596)
  Sanitize Unicode tags in Responses output (#10745)
  fix(conversation): sanitize nested tool responses (#10609)
  fix(hints): bound recursive file expansion (#10546)
  fix(providers): drop stale signed thinking blocks after a mid-conversation model switch (#10007)
  fix(desktop): clarify compact cost display (#11093)
  Index messages by (session_id, created_timestamp, id) to stop on-disk sort storms (#10874)
  docs: add tool shim guide covering when to enable, backends, and troubleshooting (#10858)
  fix(deep-link): route extension/session deep links to regular windows not standalone app windows (#10908)
  fix(ui): raise chat input z-index so slash menu appears above loading indicator (#11015)
  fix(ui): support remote working directory for external backend (#10827)
lifeizhou-ap added a commit that referenced this pull request Aug 11, 2026
* main:
  fix(mcp): prune dead notification subscribers (#11032)
  chore: remove the extension and tool count suggestion (#10869)
  feat: compaction in the GDK (#11042)
  fix(provider): retry transient errors on first stream item before ending turn (#10968)
  feat(cli): add /new to start a fresh session without restarting (#10767)
  feat(acp): title new sessions from _meta.sessionTitle (#10712)
  fix: adjust rmcp::model::Meta ref (#11107)
  Skip hook loading and lifecycle events for subagents (#10596)
  Sanitize Unicode tags in Responses output (#10745)
  fix(conversation): sanitize nested tool responses (#10609)
  fix(hints): bound recursive file expansion (#10546)
  fix(providers): drop stale signed thinking blocks after a mid-conversation model switch (#10007)
  fix(desktop): clarify compact cost display (#11093)
  Index messages by (session_id, created_timestamp, id) to stop on-disk sort storms (#10874)
  docs: add tool shim guide covering when to enable, backends, and troubleshooting (#10858)
  fix(deep-link): route extension/session deep links to regular windows not standalone app windows (#10908)
  fix(ui): raise chat input z-index so slash menu appears above loading indicator (#11015)
  fix(ui): support remote working directory for external backend (#10827)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants