feat(subagent): honor subagent_def at runtime — load def.body + def.allowed_tools#1282
Open
dcarolan1 wants to merge 1 commit into
Open
feat(subagent): honor subagent_def at runtime — load def.body + def.allowed_tools#1282dcarolan1 wants to merge 1 commit into
dcarolan1 wants to merge 1 commit into
Conversation
…llowed_tools
The plugin-loader validates subagent def manifests at worker startup
(name, body, allowed_tools, fail-loud on unknown tools) and prints a
discovery banner, but the loaded plugins array was then thrown away.
The subagent handler had no registry to consume from, so it read
data.system (defaulting to DEFAULT_SYSTEM = "You are a helpful
assistant running as a gbrain subagent") and data.allowed_tools
(defaulting to full registry) directly from job data, completely
ignoring the def the caller named via data.subagent_def.
This forced callers to embed the full system body + tool list in
every job submission, defeating the plugin system's stated purpose
of letting operators declare reusable subagent contracts.
This PR closes the gap with two coordinated changes:
1. plugin-loader.ts establishes a module-level subagent registry
(_subagentRegistry Map) populated by loadPluginsFromEnv alongside
its existing log output. Lookup-only public API:
`getSubagentDef(name): SubagentDefinition | undefined`. Lives for
the worker process lifetime; left-wins collision policy already
resolved by plugin-loader is honored.
2. subagent.ts handler resolves data.subagent_def → registered def
at job-dispatch and uses it as the default source for system prompt
+ allowed_tools:
- data.system → def.body → DEFAULT_SYSTEM
- data.allowed_tools → def.allowed_tools → full registry
Override semantics: data fields ALWAYS win when explicitly provided.
Failure modes (all fail-open, never throw):
- data.subagent_def names a def NOT in registry (worker has a stale
plugin path): warn and fall through to current behavior
- def.body is empty or whitespace-only: treat as no-body, fall to
DEFAULT_SYSTEM
- data.subagent_def absent: behavior unchanged (backward compatible)
Tests in test/subagent-def-runtime-contract.test.ts cover all four
resolution paths plus the three edge cases above. Inspection via
FakeMessagesClient.calls[0].system and .tools — exact pattern from
test/subagent-handler.test.ts. Registry seeding through plugin-loader's
__testing surface so tests don't need on-disk plugin fixtures.
Discovered while building a BAG-side subagent ("aaron-nl-to-shell-ctx")
under a stay-close-to-gbrain doctrine — caller had to re-embed the
4583-byte subagent body in every Moses-side queue.add submission for
the def to take effect.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Wires
data.subagent_defto be load-bearing at job-dispatch time: when set AND the named def exists in the plugin registry,def.bodybecomes the default system prompt anddef.allowed_toolsbecomes the default allowed tool set. Both overridable by explicitdata.system/data.allowed_tools. Backward compatible.Motivation
The plugin-loader at
src/core/minions/plugin-loader.tsdoes substantive work at worker startup:gbrain.plugin.jsonmanifests under eachGBRAIN_PLUGIN_PATHentryallowed_toolsis a subset of the brain-allowlist (fails loudly on typos)[plugin-loader] loaded 'plugin-name' v1.0.0 (N subagents)per discovered pluginBut the loaded
SubagentDefinition[]data was never consumed at runtime: the subagent handler (src/core/minions/handlers/subagent.ts:170) readsdata.system ?? DEFAULT_SYSTEManddata.allowed_tools ?? registrydirectly from job data, with no fallback to whatever the named def specified. Thedata.subagent_deffield was set + persisted but never read.Practical impact: callers had to re-embed the entire system body + allowed_tools list in every
queue.add('subagent', { ... })submission to get the def's behavior. For a 4,583-byte subagent body, that's ~4.5 KB per job inminion_jobs.dataplus the per-call complexity on the calling side.Discovered while building a BAG-side subagent (
aaron-nl-to-shell-ctx) under an internal stay-close-to-gbrain doctrine — the caller code in our TG dispatcher (Moses) was re-embedding the full subagent body on every submission, defeating the plugin system's stated purpose of letting operators declare reusable subagent contracts.Behavior
When
data.subagent_defis set AND a matching def exists in the plugin registry (populated at worker startup byloadPluginsFromEnv):systemPromptdata.system→def.body(when non-empty) →DEFAULT_SYSTEMallowed_toolsdata.allowed_tools→def.allowed_tools→ full registryOverride semantics: data fields ALWAYS win when explicitly provided. Callers retain full control; the def is a convenience default.
Fail-open behavior (handler never throws on these):
data.subagent_defset but def not in registry (e.g., worker has a stale plugin path while caller has a newer one) → warn to stderr + fall through to current behaviordef.bodyempty or whitespace-only → treat as no-body, fall through toDEFAULT_SYSTEMdata.subagent_defabsent → behavior unchanged (backward compatible)Required infrastructure: register the registry
To enable lookup,
loadPluginsFromEnvnow ALSO populates a module-level_subagentRegistry: Map<string, SubagentDefinition>alongside its existing log output. Lookup-only public API:Registry lives for the worker process lifetime. Left-wins collision policy (already enforced by plugin-loader's existing
subagentByNameMap) is honored on registration.Testing
New file
test/subagent-def-runtime-contract.test.ts— 7 test cases, all PASS in 2.72s:def.bodybecomes system prompt when subagent_def set + no data.system overridedata.systemoverrides def.body (data wins)def.allowed_toolsused when data.allowed_tools absentdata.allowed_toolsfully overrides def.allowed_tools (NOT intersection — data wins fully)Tests use the same pattern as existing
test/subagent-handler.test.ts(PGLite in-memory + FakeMessagesClient capturingparams.systemandparams.toolsfrom the Messages API call). Registry seeding via a new__testing._seedSubagentDef(def)+__testing._clearSubagentRegistry()surface so tests don't need on-disk plugin fixtures.bun run test: 7967 pass / 15 fail — 15 failures are the same pre-existing baseline (skillpack-check, check-resolvable, BrainRegistry, ConnectionManager, apply-migrations). Zero failures in plugin-loader / subagent / handler paths.Backward Compatibility
Purely additive surface. Existing callers without
data.subagent_defsee no behavior change. TheMinionJobContexttype, theSubagentHandlerDatashape, and theSubagentDefinitionshape all unchanged — only the handler's resolution logic + the addition of the registry export.Out of Scope
data.subagent_def_version; this PR doesn't add it.Notes
This is the second BAG → gbrain contribution under our internal stay-close-to-gbrain doctrine. First PR (queued separately) added
--poll-intervalflag togbrain jobs work. Both came out of measurement work for a multi-host agent fleet that uses gbrain's MinionQueue + subagent + plugin systems heavily.Happy to adjust per repo conventions — the
Co-Authored-By: Claudetrailer is on the commit but easily droppable, intersection-vs-override semantics easily swappable, CHANGELOG entry shape easily adjustable.🤖 Generated with Claude Code — happy to adjust attribution as preferred.