Skip to content

feat: disable the general-purpose subagent for repository init - #697

Open
Nick Hollon (nick-hollon-lc) wants to merge 4 commits into
mainfrom
disabling-general-purpose-subagents-in-code-interp
Open

feat: disable the general-purpose subagent for repository init#697
Nick Hollon (nick-hollon-lc) wants to merge 4 commits into
mainfrom
disabling-general-purpose-subagents-in-code-interp

Conversation

@nick-hollon-lc

Copy link
Copy Markdown
Contributor

A repository init has a named subagent for every fan-out it wants. DeepAgents' auto-added general-purpose is none of them: it inherits the main agent's whole tool set — connector tools, Claims tools, and a filesystem middleware carrying the agent's own write permissions — behind a prompt that says only "research complex questions". That makes it the one dispatch target that can author a page with no brief, no edges, and none of the claim discipline every other authoring path enforces.

DeepAgents can drop it at construction, but only through a harness profile, and profiles resolve off the model instance via getModelProvider — which recognizes ChatAnthropic, ChatOpenAI and ChatGoogleGenerativeAI and nothing else. Against deepagents 1.12.0 that leaves it reachable for gemini, openrouter and bedrock while removing it for anthropic and the ChatOpenAI-backed providers, so the boundary would hold or not depending on --provider.

So OpenWikiGeneralPurposeGuardMiddleware enforces it on the task tool itself, patched in place because that instance is the one object both callers share — the REPL's task() global resolves the tool out of request.tools and invokes it directly, no wrapToolCall runs there, and LangChain rejects substituting a wrapper to keep ToolNode's execution identity. Middleware order is therefore irrelevant.

The refusal differs per caller. A model tool call gets an error ToolMessage: throwing ends the run, since ToolNode rethrows anything raised under a wrapToolCall past handleToolErrors, and a bad subagentType should cost a turn rather than a finished wiki. The REPL gets a rejection, because its task() returns a promise into guest code where a refusal string would read as the subagent's own output.

Scoped to repository init, which is where the named subagents are — an update or local-wiki run has general-purpose and nothing else, so refusing it there would leave task() with no target rather than a better one.

Testing

test/agent/general-purpose-guard.test.ts covers the description rewrite, both call shapes, and both refusal forms, plus an end-to-end pass over a real createDeepAgent proving the guarded instance is the one bound to the model and executed by ToolNode. test/agent/claims-agent-integration.test.ts asserts the registration per command, since a missing guard fails silently. Full agent suite green (548 tests), both typechecks, lint, prettier.

An init has a named subagent for every fan-out it wants: authors write
pages against a brief and establish their own Claims, reviewers read and
never write, question-finders return a fixed text shape. DeepAgents'
auto-added general-purpose is none of those. It inherits the main agent's
whole tool set - the connector tools, the Claims tools, and a filesystem
middleware carrying the agent's own write permissions - behind a prompt
that says only "research complex questions", so it is the one dispatch
target that can author a page with no brief, no edges, and none of the
claim discipline every other authoring path enforces.

DeepAgents can drop it at construction, but only through a harness
profile, and profiles resolve off the model instance via getModelProvider,
which recognizes ChatAnthropic, ChatOpenAI and ChatGoogleGenerativeAI and
nothing else. Measured against deepagents 1.12.0 that leaves it reachable
for gemini, openrouter and bedrock while removing it for anthropic and the
ChatOpenAI-backed providers - a boundary that holds or not depending on
which --provider started the run.

So the enforcement is on the task tool itself, which is the one object
both callers share. The REPL reaches subagents through @langchain/quickjs's
task() global, which resolves the tool out of request.tools and invokes it
directly: no wrapToolCall runs there, and LangChain rejects substituting a
wrapper into request.tools outright to keep ToolNode's execution identity.
Patching in place covers both paths and makes middleware order irrelevant.

The refusal takes a different form per caller. A model tool call gets an
error ToolMessage, because throwing ends the run - ToolNode treats anything
raised under a wrapToolCall, and DeepAgents always installs one, as a
middleware error and rethrows it past handleToolErrors, so a bad
subagentType would discard a finished wiki instead of costing a turn. The
REPL gets a rejection, because its task() returns a promise into guest
code where a refusal string would read as the subagent's own output.

Scoped to repository init, which is where the named subagents are. An
update or a local-wiki run has general-purpose and nothing else, so
refusing it there would leave task() with no target rather than a better
one.
@changeset-bot

changeset-bot Bot commented Aug 21, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: bd77ffb

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
openwiki Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

An author's token cost is not the evidence it reads, it is that evidence
resent once per turn. The transcript carries every prior tool result
forward, so the bill is the sum of the context over the turns taken, and
turn count multiplies the whole accumulated read set rather than adding to
it. Two authors that read identical files and establish identical claims
differ by several times in tokens and in wall clock purely by how densely
they batch the calls.

Measured on openwiki-init-langchainplus across the same pr688 authoring
pool, gpt-5.6-luna and claude-sonnet-5 dispatch the same number of authors
for the same pages, and diverge entirely inside one author: 3.6 tool calls
per turn against 1.8, so 10 turns per page against 30, a median final
context of 57k against 101k, and 44M input tokens per wiki against 240M.
The sonnet arm made only 1.46x the tool calls - it was not reading more,
it was reading the same evidence one call at a time - and did not reach
the agent timeout's end with a finished wiki.

The wall clock matters as much as the tokens here. Authors run in a
refilling pool, so a serial author holds its slot while the pages queued
behind it wait, and the run's budget is spent on turns rather than pages.

Stated as a rule rather than a turn cap or a tuned per-turn figure. A cap
truncates a page that has not finished establishing its claims, and a
number measured on one fixture does not belong in the prompt; the
behaviour that is actually wanted is that calls which do not depend on
each other go out together. The assignment is what makes that available -
it names the evidence paths, symbols and tests up front, so the first wave
of reads is knowable before any of them return, and only the follow-up
reads are genuinely sequential.
resolve_claims encoded "an update needs statement or evidence" as an anyOf of
required-only branches on the update variant. That is valid JSON Schema, but a
provider is free to refuse a keyword combination it does not implement, and the
refusal lands on the tool rather than on the field: the schema is rejected on
the first model call, so a run ends before an author has read anything. One
optional-field guard therefore costs every Claims tool on that provider.

Verified by sending the built schema to a provider that refuses it: rejected
with the branches, accepted without them, nothing else changed.

The constraint itself is unchanged. ClaimOperationSchema still rejects an update
carrying neither statement nor evidence, with the message it already had, and
the description now states the rule so a model learns it from the contract
rather than from a rejected call.
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.

1 participant