Skip to content

🧭 feat: Let Hosts Declare Per-Call Subagent Arguments - #596

Merged
danny-avila merged 4 commits into
mainfrom
danny-avila/subagent-host-args
Oct 5, 2026
Merged

danny-avila merged 4 commits into
mainfrom
danny-avila/subagent-host-args

Conversation

@danny-avila

@danny-avila danny-avila commented Oct 4, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

A parent agent can currently only pick which subagent to run (subagent_type). Where that child runs is decided entirely by the host. In LibreChat this caused a real failure: a parent working in a BYOM workspace for one repository delegated a review to a reviewer subagent, and the child started in a different code environment where it could not see the PR.

This PR adds a generic, host-agnostic extension point so a host can let the parent choose per call, without the SDK knowing what the choice means.

What changes

Declaring arguments. A lazy subagent config (resolveAgentInputs + configId) may declare hostArgs: optional string arguments, each with a model-facing description and either an enum or a bounded free-form string (maxLength; the resolver owns any format check).

{
  type: 'reviewer',
  configId: 'reviewer@v7',
  resolveAgentInputs,
  hostArgs: {
    machine: { description: 'Machine the reviewer runs on.', enum: ['laptop', 'buildbox'] },
  },
}

Tool schema. buildSubagentToolParams adds one optional property per declared name. Enums are unioned across subagents (first declaration's description wins), each type's own values are listed in brackets beside it in the description (free-form ones as any text up to N characters), and a short OPTIONAL ARGUMENTS note is added. Free-form properties advertise their code-point maxLength and a fixed, linear-time pattern for the no-control-character rule, so schema and runtime validation agree. required is unchanged.

Validation before resolution. The tool handler reads only declared, own properties (null and blank count as omitted, other non-strings are rejected). SubagentExecutor.execute/executeInBackground then check the values against the selected subagent's declaration before an execution opens: a value outside its enum, a free-form value that breaks the bounds, or an argument declared only by a sibling returns a model-visible Error: ... and the resolver never runs.

Delivery. The resolver receives the validated, frozen values as SubagentResolveContext.hostArgs (the key is absent when the call passed none).

Determinism and replay. Values are bound to the durable execution:

  • SubagentInvocationBinding gains hostArgsDigest, so a duplicate dispatch of the same tool call with different values gets the existing "invocation changed" failure instead of sharing the in-flight child.
  • SubagentResumeExecution (the resume manifest) records hostArgsDigest — a SHA-256 of the canonical values, never the values themselves. A reconstruction whose call carries different (or no) values is rejected before the resolver runs. A malformed digest invalidates the manifest like any other malformed field.
  • Settled-output persistence derives the digest from resolvedArgs and refuses to settle against a bound invocation with a different digest, mirroring the existing description check.
  • Detached tasks include the digest in their request fingerprint, so reusing a parent tool call ID with different values is reported as a conflict.

The child's execution ID and checkpoint thread addresses are unchanged: they remain keyed by the parent tool call, so in-flight checkpoints survive an SDK upgrade.

Refusing a value at resolve time. A resolver can throw new SubagentHostArgumentError(argument, 'unavailable' | 'not_allowed'). The parent sees a fixed message naming the argument (never the value or error text), e.g. Subagent error: The requested "machine" is unavailable right now. Omit "machine" to let the host choose, or pass a different value. A refusal is honored only for an argument the call actually supplied; otherwise the generic startup failure applies. Diagnostics classify it as the new host_argument_rejected cause, and the specific message survives the detached-task boundary via SubagentResolutionError.hostArgument.

Bounds. At most 8 arguments per subagent and 16 distinct names; enums of 1–64 unique, non-blank values of at most 256 characters without control characters, and at most 256 distinct values once merged across subagents; free-form values capped at 256 code points; descriptions at most 1024 characters. Host-supplied regex patterns are not accepted (a declaration carrying pattern is rejected), so no model input reaches a host regex. Built-in names (intent, description, subagent_type, run_in_background, subagent_thread_id) are reserved. Eager, self-spawn and graph configs that declare hostArgs are rejected during normalization, since no resolver would receive them.

Compatibility

Hosts that declare nothing get byte-identical schemas, descriptions, invocation bindings, background fingerprints and resume manifests. JsonSchemaType gains optional pattern and maxLength (only the SDK's fixed free-form pattern uses pattern).

Versioning is left to the release process (this repo bumps versions in separate release commits).

Testing

  • src/tools/subagent/__tests__/hostArgs.test.ts: schema/description unchanged without declarations; enum union and per-type summaries; every declaration bound; lazy-only normalization; value checks; digest canonicalization; resolver delivery and omission; rejection before any execution opens; invocation binding against a concurrent different call; manifest digest written without values; same-digest resume reuses the persisted execution ID; changed or omitted values on resume are rejected; malformed digest invalidates the manifest; typed resolver refusal for foreground and detached tasks; background fingerprint conflict and reuse.
  • src/specs/subagent-host-args.test.ts: a full Run through the real ToolNode and LangChain schema validation — the parent's choice reaches the resolver, omission falls back to the host default, and an argument the selected type does not declare returns a model-visible error.
  • Mutation check: disabling the resume digest comparison and the invocation digest comparison makes three of the new tests fail.
  • npx tsc --noEmit, eslint and import sorting on touched files, npm run check:circular-deps, and the tools/subagent/graph suites (2240 tests) pass.

LibreChat consumes this to let a parent choose the machine/workspace for a subagent per call (follow-up PR in danny-avila/LibreChat).

Hosts can now declare optional, host-validated string arguments on a lazy
subagent config (`SubagentConfig.hostArgs`). The subagent tool advertises
them as optional properties (enums unioned across subagents, each type's own
values listed beside it), the executor checks a call's values against the
selected subagent's declaration before an execution opens, and the lazy
resolver receives them as `SubagentResolveContext.hostArgs`.

Values are bound to the durable execution: the invocation binding and the
resume manifest carry a SHA-256 digest of them (never the values), so a
reconstruction with different arguments is rejected before the resolver
runs, and detached tasks include the digest in their request fingerprint.
A resolver can refuse a value with `SubagentHostArgumentError`, which the
parent sees as a fixed message naming the argument.

Hosts that declare nothing get the same schema, description, fingerprints
and manifests as before.
@danny-avila

Copy link
Copy Markdown
Collaborator Author

@codex review

Pass 1 of 4 on head 2a7449d.

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 4, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-04T22:55:01.811277Z f0b8712 Manual request
ℹ️ 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@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: 2a7449d947

ℹ️ 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 src/tools/subagent/hostArgs.ts Outdated
Comment on lines +357 to +358
isBoundedText(value, maxLength) &&
(pattern == null || pattern.test(value))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Reject backtracking-prone host argument patterns

When a host declares a vulnerable pattern such as (a+)+$, the model controls the string passed to this synchronous RegExp.test, so a non-matching value can block the Node event loop. The 256-character value cap is not protective: even 28 a characters followed by ! took over a second locally, and longer allowed inputs grow exponentially. Validate declarations for unsafe regex constructs or evaluate patterns with a non-backtracking/timeout-bounded engine before exposing this to model input.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in ad7f03c: pattern support is removed. Free-form host arguments are bounded strings only (at most 256 characters, no control characters), a declaration that carries pattern is rejected at normalization, and any format check belongs to the host resolver, which already re-validates every value. No model input reaches a host-supplied regex.

Comment thread src/tools/subagent/hostArgs.ts Outdated
Comment on lines +254 to +259
const [pattern] = entry.patterns;
return {
type: 'string',
description: entry.description,
maxLength: entry.maxLength,
...(entry.patterns.size === 1 && pattern != null ? { pattern } : {}),

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 Anchor patterns in the generated tool schema

When a free-form declaration uses an unanchored pattern such as [a-z]+, JSON Schema interprets the emitted pattern as a substring match, while checkValue wraps the same declaration with ^(?:...)$ and requires a full-string match. Consequently, schema validation or constrained generation can accept a value such as abc!, only for the executor to reject the supposedly schema-valid call. Emit the same anchored expression in the tool schema that runtime validation uses.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in ad7f03c by removing pattern entirely (see the P1 thread), so the schema and runtime validation agree: free-form properties advertise only maxLength, which runtime enforces identically.

A host-declared pattern ran model-controlled input through a synchronous
regex, so a backtracking-prone pattern could block the event loop, and the
schema emitted it unanchored while runtime validation anchored it.
Free-form host arguments are now bounded strings only (length and no
control characters); declarations carrying a pattern are rejected and the
resolver owns any format check.
@danny-avila

Copy link
Copy Markdown
Collaborator Author

@codex review

Pass 2 of 4 on head ad7f03c.

@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: ad7f03c8f4

ℹ️ 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 +2764 to +2766
resumeExecution != null &&
resumeExecution.hostArgsDigest !==
getSubagentHostArgsDigest(params.hostArgs)

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 Validate host arguments before replaying settled output

On a durable replay with an already persisted settlement, ToolNode calls getSettledToolOutput, and restoreSettledToolOutput can return the checkpointed result without ever reaching this comparison in executeOnce. If the reconstructed call keeps its tool-call ID but changes a host argument such as machine or workspace, the old result is therefore silently reused instead of returning the invocation-changed failure. Apply the digest check in the settled-output restoration path as well.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Not changing this one. A settled replay is keyed to the parent tool call recorded in the checkpointed AIMessage, and the host arguments are part of that call's args, so a replayed call cannot carry different values without being a different message. The persisted digest is compared on the paths where a child is resolved or settled against a bound invocation (executeOnce and persistSettledToolOutput). Comparing call.args here would also be wrong in the other direction: they are the pre-hook args, while the bound digest is taken from the post-hook values the tool actually received, so a PreToolUse hook that legitimately rewrote a host argument would make every replay fail. This mirrors how the replay path already treats description, which it also does not re-compare.

Comment thread src/tools/subagent/hostArgs.ts Outdated
Comment on lines +64 to +65
value.length > 0 &&
value.length <= maxLength &&

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 Count schema lengths by Unicode code points

For free-form arguments containing astral Unicode characters, this runtime bound disagrees with the emitted JSON Schema: JSON Schema maxLength counts Unicode code points, whereas JavaScript value.length counts UTF-16 code units. For example, a declaration with maxLength: 1 advertises "😀" as valid, but the executor rejects it as length 2, so constrained/schema-validated model output can still fail at execution. Count code points here, such as via iteration over the string, to enforce the same limit the schema advertises.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in f572f5c: free-form lengths are counted in code points, matching JSON Schema maxLength; a test covers an astral-plane value at the boundary.

Comment on lines +85 to +86
for (const value of values) {
if (!isBoundedText(value, SUBAGENT_HOST_ARG_LIMITS.valueLength)) {

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 Reject blank enum values during declaration validation

When a host declares a whitespace-only enum member such as " ", isBoundedText accepts it and the generated schema advertises it as a valid choice, but pickSubagentHostArgInput later treats that same value as omitted because value.trim() === ''. The resolver therefore never receives a schema-valid selection. Reject whitespace-only enum members here, or preserve them during input extraction, so declaration and execution semantics agree.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in f572f5c: blank enum values are rejected at declaration time, so every advertised choice survives input extraction.

Comment on lines +297 to +299
for (const name of names) {
const value = values[name];
if (value == null) {

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 Ignore inherited properties when reading optional arguments

If a host declares an otherwise valid name that exists on Object.prototype, such as constructor, toString, or valueOf, omitting that optional argument reads the inherited function here rather than undefined, so the call is rejected with must be a string instead of allowing the host default. Check that the raw input owns the property before reading it, or use a null-prototype lookup object.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in f572f5c: input extraction reads only own properties, so an omitted argument named like a prototype member (e.g. constructor) is treated as omitted.

Comment thread src/tools/SubagentTool.ts Outdated
Comment on lines +37 to +38
const HOST_ARGS_DESCRIPTION =
'\n\nOPTIONAL ARGUMENTS:\n- Some types accept extra arguments, listed in brackets after the type. Each is optional: omit it to let the host choose, and pass only a value listed for the selected type.';

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 Distinguish free-form arguments in the model instructions

When any declaration is free-form, this instruction still tells the parent to pass only a listed value, while the per-type summary represents that argument merely as worktree: text. That conflicts with the schema, which permits arbitrary bounded text, and can cause the model to omit the argument or send the literal text instead of the requested path or identifier. Describe listed choices as applying only to enum arguments and explicitly permit bounded text for free-form ones.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in f572f5c: the summary now reads name: any text up to N characters for free-form arguments, and the instruction distinguishes listed values from free text.

Comment on lines +123 to +126
if (
typeof argument !== 'string' ||
!HOST_ARGUMENT_NAME_PATTERN.test(argument)
) {

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 Reject built-in names in host-argument errors

If a resolver mistakenly throws SubagentHostArgumentError with a reserved built-in name such as subagent_type or description, this duplicate syntax-only check accepts it and produces a model-facing instruction to omit a required built-in field, leading subsequent retries to fail schema validation rather than recover. Reuse the declaration name validator here so typed rejections can name only valid host arguments.

AGENTS.md reference: AGENTS.md:L32-L35

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in f572f5c: typed refusals reuse the declaration name validator, so a refusal naming a built-in argument falls back to the generic startup failure; covered by a test.

- Count free-form lengths in code points, as JSON Schema maxLength does.
- Reject blank enum values, which input extraction treats as omitted.
- Read only own properties of the tool input, so prototype names such as
  constructor are omitted rather than rejected.
- Tell the model that free-form arguments take any short text.
- Accept a typed resolver refusal only for a declarable argument name.
@danny-avila

Copy link
Copy Markdown
Collaborator Author

@codex review

Pass 3 of 4 on head f572f5c.

@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: f572f5c544

ℹ️ 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".

return {
type: 'string',
description: entry.description,
maxLength: entry.maxLength,

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 Advertise the free-form character restriction in the schema

For a free-form argument, the generated schema enforces only maxLength, so a schema validator or constrained model can accept a value containing a newline or another control character. The executor later rejects that same value because checkValue delegates to isBoundedText, which forbids control characters, making schema-valid tool calls fail at runtime. Add a fixed safe schema constraint for the runtime restriction, or otherwise keep schema and execution validation aligned.

AGENTS.md reference: AGENTS.md:L32-L35

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in f0b8712: free-form properties now also advertise a fixed SDK pattern, ^[^\u0000-\u001f\u007f]*$ (no host input, linear-time), which is exactly the runtime's control-character rule; length is already aligned via code-point maxLength.

Comment on lines +226 to +228
if (!entry.seenValues.has(value)) {
entry.seenValues.add(value);
entry.values.push(value);

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 Enforce the enum cap after merging declarations

When multiple subagents declare the same argument with disjoint enums, each declaration may contribute the full 64 allowed values and this loop appends all of them without checking the merged size. Because the number of subagent configs is not bounded, the emitted property enum and tool prompt can grow arbitrarily large despite enumValues: 64, potentially exceeding provider schema or context limits. Reject an oversized merged enum or apply a separate global cap before emitting the schema.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in f0b8712: the enum merged across subagents is capped at 256 distinct values (SUBAGENT_HOST_ARG_LIMITS.mergedEnumValues) and building the tool throws beyond it; covered by a test.

Comment thread src/tools/subagent/diagnostics.ts Outdated
failure: SubagentHostArgumentFailure
): string {
const { argument } = failure;
const omit = `Omit "${argument}" to let the host choose, or pass another listed value.`;

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 Avoid referring to listed choices for free-form refusals

When a resolver rejects a free-form argument such as worktree, this formatter tells the model to pass another listed value even though that declaration has no listed choices. The main tool description now distinguishes free-form arguments, but this remaining failure-path instruction contradicts it and gives the model no actionable retry guidance. Use wording that applies to both declaration kinds, or tailor the message using the selected argument specification.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in f0b8712: refusal messages now say "Omit it to let the host choose, or pass a different value", which holds for both free-form and enumerated arguments.

Comment thread src/tools/subagent/diagnostics.ts Outdated
Comment on lines +122 to +123
if (typeof argument !== 'string' || !isSubagentHostArgName(argument)) {
return undefined;

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 Verify refusal names against the selected invocation

Fresh evidence at the current head is that this shared validator checks only syntax and reserved names, not whether the argument was declared by the selected subagent and actually supplied in this invocation. A resolver typo such as machin, or a refusal for an omitted host-defaulted argument, is therefore exposed as a specific model-facing instruction to omit or replace a field the model never passed and may not have in its schema. Compare the failure name with the selected config and validated hostArgs; otherwise fall back to the generic startup failure.

AGENTS.md reference: AGENTS.md:L32-L35

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in f0b8712: a typed refusal is honored only when its argument was supplied in this invocation's validated host args (which already implies the selected subagent declares it); otherwise the failure falls back to the generic startup message. Covered by a test where the resolver refuses an argument the call did not pass.

- Advertise free-form arguments' no-control-character rule with a fixed,
  linear-time schema pattern so schema and runtime validation agree.
- Cap the enum merged across subagents at 256 distinct values.
- Honor a typed resolver refusal only for an argument this call supplied.
- Word refusals so they apply to free-form and enumerated arguments.
@danny-avila

Copy link
Copy Markdown
Collaborator Author

@codex review

This is the final review for this PR (pass 4 of 4) of head f0b8712. Please flag only blocking issues: correctness, security, or data loss.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Keep it up!

Reviewed commit: f0b8712387

ℹ️ 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".

@danny-avila
danny-avila merged commit ebf3d01 into main Oct 5, 2026
13 checks passed
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