Skip to content

fix(spawn): register system-prompt factory for sub-sessions so prefix-placement hooks can compose - #257

Merged
Brian Krabach (bkrabach) merged 1 commit into
mainfrom
fix/spawn-register-system-prompt-factory
Aug 5, 2026
Merged

fix(spawn): register system-prompt factory for sub-sessions so prefix-placement hooks can compose#257
Brian Krabach (bkrabach) merged 1 commit into
mainfrom
fix/spawn-register-system-prompt-factory

Conversation

@bkrabach

Copy link
Copy Markdown
Collaborator

Summary

Delegated sub-agent sessions were paying an uncached token tax on every single LLM request, for the entire life of the sub-session.

session_spawner.py injected the agent's system instruction as a STATIC message via add_message() and never registered a system-prompt factory.

The skills-visibility hook defaults to placement="prefix", which works by WRAPPING the context module's registered system-prompt factory so the skills index rides the provider's stable cached prefix. At tool-skills/hooks.py:216-221 it explicitly REFUSES to wrap when no factory is registered — correctly, because in context-simple a registered factory takes precedence over static system messages (context-simple/__init__.py:268-271), so wrapping a nonexistent factory would silently DROP the agent's instruction.

Result: every delegated sub-agent hit the fallback path and re-injected the entire skills index as a fresh per-request message on every provider:request, outside the cached prefix, plus emitted a confusing warning blaming the context module.

The context module was never at fault. context-simple implements set_system_prompt_factory at __init__.py:195. amplifier-foundation already does this correctly in both its session paths (_prepared.py:661,664 and :874,875). app-cli's session_spawner — the path that actually runs every delegate/Task sub-agent — was the only gap.

The Fix

amplifier_app_cli/session_spawner.py:788 — prefer factory registration, fall back to add_message, mirroring the foundation _prepared.py spawn path. The instruction content is byte-identical to before (system_instruction is already resolved at that point; the factory is a closure returning that resolved string, bound to a local to avoid late-binding). No re-expansion per request — deliberately out of scope.

Verification Evidence

Test suite, run by the reviewer independently:

  • baseline on main: 1297 passed, 1 skipped, 13 deselected, 1 xfailed
  • on this branch: 1301 passed, 1 skipped, 13 deselected, 1 xfailed
  • delta: +4, exactly the new tests. No regressions.

End-to-end mechanism proof driving the REAL context-simple module and the REAL SkillsVisibilityHook (108-skill synthetic catalog):

BEFORE FIX  (session_spawner add_message only)
  prefix placement achieved : False
  hook action               : inject_context
  per-request injection size: 2,218 chars
  skills index IN system msg: False  (system msg 43 chars)
  agent instruction present : True
  WARNINGS                  : 1
    ! visibility.placement='prefix' (the default) but the context module offers no system-prompt factory surface...

AFTER FIX   (session_spawner registers factory)
  prefix placement achieved : True
  hook action               : continue
  per-request injection size: 0 chars
  skills index IN system msg: True  (system msg 2,263 chars)
  agent instruction present : True
  WARNINGS                  : 0

Critically, agent instruction present: True in BOTH cases — the fix does not drop the system instruction, which is precisely the hazard the hook's refusal-to-wrap guard exists to prevent.

Note on magnitude: The proof used 108 synthetic skills with short descriptions (~2.2KB). A real catalog carries ~19,766 bytes of skill descriptions (~5k tokens), so the real-world per-request saving is substantially larger. The mechanism is what the proof establishes; the magnitude scales with the actual catalog.

Lint/typecheck:

  • ruff check on both changed files → All checks passed (baseline and post-change)
  • pyright session_spawner.py → 3 errors, all pre-existing and unrelated (lines 486, 577, 578), identical before and after
  • Pre-existing ruff format debt in test_session_spawner.py present in baseline too

Also checked for the same bug elsewhere in the package: The only other add_message({"role": "system"...})-shaped call site is the resume/transcript-restore path (~line 1298), which replays a saved transcript message-by-message. Different semantics, not the same bug, deliberately left unchanged.

…-placement hooks can compose

The session_spawner spawn_sub_session path was injecting the system instruction
as a static message via add_message() and never registered a system-prompt
factory. The skills-visibility hook uses placement='prefix' by default, which
works by composing/wrapping the context module's registered factory. At
tool-skills/hooks.py:216-221 it explicitly refuses to wrap when no factory
exists -- correctly, because in context-simple a registered factory takes
precedence over static system messages (context-simple/__init__.py:268-271),
so wrapping a nonexistent factory would silently drop the agent's instruction.

Result: every delegated sub-agent hit the fallback path and re-injected the
entire skills index on every provider:request outside the cached prefix, plus
emitted a confusing warning blaming the context module.

The context module was never at fault -- context-simple implements
set_system_prompt_factory at __init__.py:195. amplifier-foundation already
does this correctly in both its session paths (_prepared.py:661,664 and
:874,875). app-cli's session_spawner -- the path that actually runs every
delegate/Task sub-agent -- was the only gap.

This fix mirrors the foundation _prepared.py spawn path: prefer factory
registration, fall back to add_message. The instruction content is byte-identical
to before; the factory is a closure returning that resolved string, bound to a
local to avoid late-binding risk.

Real verification (driving the actual context-simple module and SkillsVisibilityHook
with 108-skill catalog):

BEFORE FIX  (session_spawner add_message only)
  prefix placement achieved : False
  hook action               : inject_context
  per-request injection     : 2,218 chars
  skills index IN system msg: False
  agent instruction present : True
  warnings                  : 1

AFTER FIX   (session_spawner registers factory)
  prefix placement achieved : True
  hook action               : continue
  per-request injection     : 0 chars
  skills index IN system msg: True
  agent instruction present : True
  warnings                  : 0

Test suite delta: +4 new tests, 1301 passed (baseline 1297), no regressions.

Generated with [Amplifier](https://github.com/microsoft/amplifier)

Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
@bkrabach
Brian Krabach (bkrabach) merged commit 5462f1e into main Aug 5, 2026
7 checks passed
@bkrabach
Brian Krabach (bkrabach) deleted the fix/spawn-register-system-prompt-factory branch August 5, 2026 18:44
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