feat: disable the general-purpose subagent for repository init - #697
Open
Nick Hollon (nick-hollon-lc) wants to merge 4 commits into
Open
feat: disable the general-purpose subagent for repository init#697Nick Hollon (nick-hollon-lc) wants to merge 4 commits into
Nick Hollon (nick-hollon-lc) wants to merge 4 commits into
Conversation
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 detectedLatest commit: bd77ffb The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
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.
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.
A repository init has a named subagent for every fan-out it wants. DeepAgents' auto-added
general-purposeis 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 recognizesChatAnthropic,ChatOpenAIandChatGoogleGenerativeAIand nothing else. Against deepagents 1.12.0 that leaves it reachable forgemini,openrouterandbedrockwhile removing it foranthropicand the ChatOpenAI-backed providers, so the boundary would hold or not depending on--provider.So
OpenWikiGeneralPurposeGuardMiddlewareenforces it on thetasktool itself, patched in place because that instance is the one object both callers share — the REPL'stask()global resolves the tool out ofrequest.toolsand invokes it directly, nowrapToolCallruns 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 awrapToolCallpasthandleToolErrors, and a badsubagentTypeshould cost a turn rather than a finished wiki. The REPL gets a rejection, because itstask()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-purposeand nothing else, so refusing it there would leavetask()with no target rather than a better one.Testing
test/agent/general-purpose-guard.test.tscovers the description rewrite, both call shapes, and both refusal forms, plus an end-to-end pass over a realcreateDeepAgentproving the guarded instance is the one bound to the model and executed by ToolNode.test/agent/claims-agent-integration.test.tsasserts the registration per command, since a missing guard fails silently. Full agent suite green (548 tests), both typechecks, lint, prettier.