mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
feat(core): improve subagent delegation defaults and guardrails (#7048)
* docs(design): define default background subagents * feat(core): improve subagent delegation defaults * docs(core): cross-reference the three background-classification sites Add pointer comments linking the core dispatch source of truth (AgentTool.execute) and its two UI mirrors (web-shell isBackgroundSubAgentToolCall, desktop detectBackgroundEvents) so the replicated top-level-agent background heuristic is not changed in isolation. Addresses PR review feedback. * fix(core): align background classification for fork and named-teammate launches Address review feedback on the background-classification rule so core dispatch and the two UI classifiers stay consistent: - core: exclude a name-without-active-team launch from the default-background path so it stays foreground, matching both UI classifiers (which exclude name). Previously such a launch was backgrounded by core but tracked as foreground by the UIs. - web-shell and desktop classifiers: exclude subagent_type "fork" from the default-background heuristic, mirroring core's !isForkRequested guard. A top-level fork request with an omitted flag runs foreground in core but was classified as background by the UIs. - add a core dispatch test asserting a working_dir launch with an omitted run_in_background flag stays in the foreground. * test: cover fork/background classification and precedence per review feedback Address unresolved review threads on PR #7048: - Add web-shell and desktop UI classifier tests asserting an omitted-flag `subagent_type: "fork"` launch stays in the foreground, verifying the documented `!isForkRequested` parity with core dispatch. - Add a core AgentTool test asserting an explicit `run_in_background: false` overrides a subagent config with `background: true`, locking in the `run_in_background ?? config` precedence against a `||` regression. - Harden the Explore read-only prompt: pipelines must not send data to a network endpoint (no curl/wget/nc), closing the `cat file | curl` exfiltration gap. * fix(core): restore general no-unnecessary-files guard in general-purpose prompt Address review feedback: the rewritten general-purpose prompt dropped the broad guard against creating unrequested files, keeping only the documentation-specific line. Restore a general 'do not create files unless necessary' guard so speculative utility/config files are not created. * test(desktop): cover named-teammate foreground guard in detectBackgroundEvents Add a desktop tool-matching test asserting a top-level Agent with a `name` set (named teammate) stays foreground and emits no task_backgrounded event, mirroring the web-shell classifier's named-teammate coverage and the existing fork-exclusion test. * test(core): cover named-teammate foreground dispatch when flag omitted Add a core-dispatch test asserting a top-level Agent launch with `name` set and `run_in_background` omitted stays foreground when no team is active, guarding the `this.params.name === undefined` exclusion in backgroundRequested directly (previously only covered by the UI classifiers). --------- Co-authored-by: Claude <[email protected]>
This commit is contained in:
1 parent
6dce543491
commit
d4db5fcfab
19 files changed
+855
-73
No files matched your search
@@ -0,0 +1,132 @@
|
||||
# Default Background Subagents
|
||||
|
||||
## Summary
|
||||
|
||||
Top-level one-shot subagents should run in the background by default. Callers
|
||||
that require an inline result can opt out with `run_in_background: false`.
|
||||
Nested subagent launches and launches pinned to a caller-owned `working_dir`
|
||||
remain foreground operations because the current background lifecycle cannot
|
||||
safely return results to those callers. Forks and named Agent Teams teammates
|
||||
keep their existing behavior.
|
||||
|
||||
## Motivation
|
||||
|
||||
The Agent tool already supports background execution across interactive,
|
||||
headless, and SDK consumers, but callers must currently request it with
|
||||
`run_in_background: true` or select an agent declared with `background: true`.
|
||||
This makes ordinary delegation block the parent by default even when the parent
|
||||
could continue independent work. Making background execution the top-level
|
||||
default better matches the tool's parallel delegation guidance while retaining
|
||||
an explicit foreground escape hatch for result-dependent work.
|
||||
|
||||
## Goals
|
||||
|
||||
- Run top-level one-shot subagents in the background when
|
||||
`run_in_background` is omitted.
|
||||
- Preserve `run_in_background: false` as an explicit foreground opt-out.
|
||||
- Preserve the existing completion notification, cancellation, concurrency,
|
||||
permission, transcript, and headless hold-back paths.
|
||||
- Keep unsafe or unsupported launch shapes on their existing foreground path.
|
||||
- Document the compatibility impact for skills and callers that require an
|
||||
inline result.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Background execution for nested subagent launches.
|
||||
- Background execution in a caller-owned `working_dir`.
|
||||
- Changes to fork context inheritance or fork lifecycle.
|
||||
- Changes to named Agent Teams teammate behavior.
|
||||
- A new global setting for the default.
|
||||
- Redesigning background notification routing or task ownership.
|
||||
|
||||
## Behavior
|
||||
|
||||
The runtime resolves one-shot subagent execution in this order:
|
||||
|
||||
1. A named Agent Teams teammate uses the existing teammate path.
|
||||
2. A valid top-level fork uses the existing detached fork path.
|
||||
3. A nested ordinary subagent runs in the foreground, even when background was
|
||||
requested, so its result returns to the nested caller.
|
||||
4. An ordinary subagent with `working_dir` and no configured background
|
||||
default runs in the foreground because the caller owns that worktree's
|
||||
lifecycle. An explicit or configured background request remains invalid.
|
||||
5. For any other top-level ordinary subagent:
|
||||
- `run_in_background: false` runs in the foreground.
|
||||
- `run_in_background: true` runs in the background.
|
||||
- an omitted `run_in_background` runs in the background.
|
||||
|
||||
The existing agent-level `background: true` frontmatter remains accepted for
|
||||
compatibility. It is no longer necessary to obtain the new top-level default.
|
||||
An explicit tool-call value of `run_in_background: false` takes precedence and
|
||||
selects the foreground path.
|
||||
|
||||
## Implementation
|
||||
|
||||
The dispatch decision remains in the Agent tool so every consumer receives the
|
||||
same behavior. The background decision should distinguish three concepts:
|
||||
|
||||
- whether the caller explicitly opted out;
|
||||
- whether the launch is top-level;
|
||||
- whether the launch shape can safely detach.
|
||||
|
||||
The implementation should reuse the existing background branch rather than add
|
||||
a second launch path. Tool schema text and model-facing usage guidance should
|
||||
describe background as the default and tell callers to pass
|
||||
`run_in_background: false` when they need the result inline.
|
||||
|
||||
The `working_dir` exception must be resolved before the existing incompatibility
|
||||
guard. An omitted background parameter must not turn previously valid pinned
|
||||
review launches into errors. An explicit `run_in_background: true` or an agent
|
||||
configured with `background: true` remains incompatible with `working_dir`,
|
||||
preserving the existing safety check.
|
||||
|
||||
## Result Flow
|
||||
|
||||
A default-background launch returns the existing background-launch response to
|
||||
the parent immediately. The detached task remains registered with the existing
|
||||
background task registry. When it terminates, the registry emits the existing
|
||||
completion, failure, or cancellation notification and the parent processes the
|
||||
result in a later turn. No new message format or SDK event is introduced.
|
||||
|
||||
Foreground opt-outs continue through the existing synchronous branch and return
|
||||
the sanitized subagent result inline.
|
||||
|
||||
## Documentation
|
||||
|
||||
The subagent user guide should state that named one-shot subagents run in the
|
||||
background by default at the top level and explain
|
||||
`run_in_background: false`. The fork comparison should focus on context
|
||||
inheritance and result semantics rather than claiming that all named subagents
|
||||
block the parent.
|
||||
|
||||
## Testing
|
||||
|
||||
Unit coverage should verify:
|
||||
|
||||
- an ordinary top-level subagent with an omitted flag launches in the
|
||||
background;
|
||||
- `run_in_background: false` returns the result inline;
|
||||
- `run_in_background: true` retains the existing background behavior;
|
||||
- a nested launch with an omitted or true flag remains foreground;
|
||||
- a `working_dir` launch with an omitted flag remains foreground;
|
||||
- an explicit background request with `working_dir` remains rejected;
|
||||
- fork and named teammate behavior remain unchanged;
|
||||
- the tool schema and usage guidance advertise the new default and opt-out.
|
||||
|
||||
Existing tests that intentionally exercise the foreground branch should pass
|
||||
`run_in_background: false` so their expectation is explicit. The focused Agent
|
||||
tool test file, build, and typecheck are required before submission. A manual
|
||||
interactive E2E check should confirm that a normal delegation returns control
|
||||
immediately and later delivers a completion notification, while an explicit
|
||||
foreground delegation blocks and returns its result inline.
|
||||
|
||||
## Risks and Compatibility
|
||||
|
||||
The change is behaviorally breaking for prompts, skills, and programmatic
|
||||
callers that omit the flag and assume the Agent tool response contains the
|
||||
subagent result. Those callers must pass `run_in_background: false`.
|
||||
|
||||
Default background execution can also increase concurrent work. Existing global
|
||||
concurrency limits and queueing remain the controlling safeguards. Permission
|
||||
handling, shutdown, and headless waiting already use the established background
|
||||
task lifecycle and are not changed by this design.
|
||||
@@ -0,0 +1,32 @@
|
||||
# Subagent Prompt Guardrails
|
||||
|
||||
## Motivation
|
||||
|
||||
The Agent tool currently encourages broad parallel delegation and says that
|
||||
subagent output should generally be trusted. The built-in prompts also omit a
|
||||
few execution and verification expectations, while the Explore and fork prompts
|
||||
contain unsafe or contradictory guidance.
|
||||
|
||||
## Design
|
||||
|
||||
- Tell the parent agent to delegate only bounded, independent work, keep
|
||||
immediate critical-path work local, avoid duplicate work, and give parallel
|
||||
code-writing agents disjoint write scopes.
|
||||
- Require the parent to review claims and code changes before integrating or
|
||||
relaying a subagent result.
|
||||
- Simplify the general-purpose prompt and add scope, preservation,
|
||||
verification, uncertainty, and structured reporting expectations.
|
||||
- Narrow Explore's stateful tool surface by removing task, memory, and user
|
||||
question tools from its allowlist. Permit shell pipelines while continuing
|
||||
to prohibit writes in its prompt.
|
||||
- Stop requiring fork agents to commit changes unless the directive explicitly
|
||||
asks for a commit.
|
||||
|
||||
Context inheritance and the default background-execution behavior are outside
|
||||
this change.
|
||||
|
||||
## Verification
|
||||
|
||||
Focused unit tests assert the parent guidance, built-in prompt contents,
|
||||
Explore's tool allowlist, and fork reporting rule. The core package build and
|
||||
typecheck provide the broader compile-time check.
|
||||
@@ -13,7 +13,7 @@ Use `agent` to launch a specialized subagent to handle complex, multi-step tasks
|
||||
- `description` (string, required): A short (3-5 word) description of the task for user visibility and tracking purposes.
|
||||
- `prompt` (string, required): The detailed task prompt for the subagent to execute. Should contain comprehensive instructions for autonomous execution.
|
||||
- `subagent_type` (string, optional): The type of specialized agent to use for this task. Defaults to `general-purpose` if omitted.
|
||||
- `run_in_background` (boolean, optional): Set to `true` to run the agent in the background. You will be notified when it completes.
|
||||
- `run_in_background` (boolean, optional): Defaults to `true` for top-level one-shot agents. Set to `false` to wait for the result inline. Nested agents run in the foreground. Caller-owned `working_dir` launches default to foreground and reject explicit or configured background execution.
|
||||
- `isolation` (string, optional): Set to `"worktree"` to run the agent in an isolated git worktree.
|
||||
|
||||
## How to use `agent` with Qwen Code
|
||||
@@ -24,7 +24,7 @@ When you use the Agent tool, the subagent will:
|
||||
|
||||
1. Receive the task prompt with full autonomy
|
||||
2. Execute the task using its available tools
|
||||
3. Return a final result message
|
||||
3. Report a completion notification by default, or return a final result message when run in the foreground
|
||||
4. Terminate (subagents are stateless and single-use)
|
||||
|
||||
Usage:
|
||||
@@ -33,6 +33,8 @@ Usage:
|
||||
agent(description="Brief task description", prompt="Detailed task instructions for the subagent", subagent_type="agent_name")
|
||||
```
|
||||
|
||||
Set `run_in_background=false` when the current turn must use the subagent result before continuing.
|
||||
|
||||
## Available Subagents
|
||||
|
||||
The available subagents depend on your configuration. Common subagent types might include:
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
# Default Background Subagents Implementation Plan
|
||||
|
||||
## Scope
|
||||
|
||||
Implement the approved design in
|
||||
`docs/design/2026-07-16-default-background-subagents.md`: top-level one-shot
|
||||
subagents default to background execution, `run_in_background: false` opts into
|
||||
foreground execution, nested and caller-owned `working_dir` launches stay
|
||||
foreground, and forks plus named teammates keep their existing behavior.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Record the current global CLI behavior in
|
||||
`.qwen/e2e-tests/default-background-subagents.md` by launching a normal
|
||||
subagent without `run_in_background` and confirming that it returns inline.
|
||||
2. Add focused failing unit tests for the new core dispatch precedence:
|
||||
omitted flag defaults to background, explicit false stays foreground,
|
||||
nested launches stay foreground, and an omitted flag with `working_dir`
|
||||
stays foreground.
|
||||
3. Change the Agent tool's single background-routing decision and update its
|
||||
schema description, model-facing guidance, fork comments, and stale tests
|
||||
that intentionally depend on foreground execution.
|
||||
4. Update bundled workflows that require inline aggregation to opt out
|
||||
explicitly.
|
||||
5. Update UI consumers that infer background execution from tool input so an
|
||||
omitted flag is classified consistently while explicit foreground,
|
||||
`working_dir`, and named teammate calls are not misclassified.
|
||||
6. Update user and developer documentation for the default and opt-out.
|
||||
7. Run focused core, Web Shell, Desktop shared-package, and CLI tests, then run
|
||||
the repository build and typecheck.
|
||||
8. Build and bundle the CLI, rerun the E2E scenarios against the branch, and
|
||||
record the observed before/after evidence.
|
||||
9. Review the complete diff twice, fix any findings, rerun affected checks,
|
||||
commit the implementation, run the available final PR gate, push, and open
|
||||
a draft PR using the repository template.
|
||||
|
||||
## Dispatch Precedence
|
||||
|
||||
For ordinary one-shot agents, an explicit tool parameter wins over the agent
|
||||
definition. When the parameter is omitted, `background: true` retains its
|
||||
existing meaning; otherwise a safe top-level launch defaults to background.
|
||||
Caller-owned `working_dir` launches default to foreground, while an explicit
|
||||
background request remains invalid. The final background path is still gated
|
||||
by the top-level-session check, so nested launches downgrade to foreground.
|
||||
|
||||
## Verification Targets
|
||||
|
||||
- `packages/core/src/tools/agent/agent.test.ts`
|
||||
- `packages/web-shell/client/adapters/toolClassification.test.ts`
|
||||
- `packages/desktop/packages/shared/src/agent/__tests__/tool-matching.test.ts`
|
||||
- `packages/cli/src/ui/commands/forkCommand.test.ts`
|
||||
- `npm run build`
|
||||
- `npm run typecheck`
|
||||
- bundled interactive/headless E2E scenarios from the test plan
|
||||
@@ -14,16 +14,16 @@ Subagents are independent AI assistants that:
|
||||
|
||||
## Fork Subagent
|
||||
|
||||
In addition to named subagents, Qwen Code supports **forking** — selected explicitly with `subagent_type: "fork"` (available in interactive sessions). A fork inherits the parent's full conversation context and runs detached in the background. Omitting `subagent_type` does **not** fork; it launches the general-purpose subagent, which runs to completion and returns its result inline.
|
||||
In addition to named subagents, Qwen Code supports **forking** — selected explicitly with `subagent_type: "fork"` (available in interactive sessions). A fork inherits the parent's full conversation context and runs detached in the background. Omitting `subagent_type` does **not** fork; it launches the general-purpose subagent. Top-level named subagents run in the background by default and deliver their results through completion notifications. Set `run_in_background: false` when the current turn must wait for the result inline.
|
||||
|
||||
### How Fork Differs from Named Subagents
|
||||
|
||||
| | Named Subagent | Fork Subagent |
|
||||
| ------------- | --------------------------------- | ----------------------------------------------------- |
|
||||
| Context | Starts fresh, no parent history | Inherits parent's full conversation history |
|
||||
| System prompt | Uses its own configured prompt | Uses parent's exact system prompt (for cache sharing) |
|
||||
| Execution | Blocks the parent until done | Runs in background, parent continues immediately |
|
||||
| Use case | Specialized tasks (testing, docs) | Parallel tasks that need the current context |
|
||||
| | Named Subagent | Fork Subagent |
|
||||
| ------------- | -------------------------------------------------------------- | ----------------------------------------------------- |
|
||||
| Context | Starts fresh, no parent history | Inherits parent's full conversation history |
|
||||
| System prompt | Uses its own configured prompt | Uses parent's exact system prompt (for cache sharing) |
|
||||
| Execution | Background by default; supports an explicit foreground opt-out | Always detached; parent continues immediately |
|
||||
| Use case | Specialized tasks (testing, docs) | Parallel tasks that need the current context |
|
||||
|
||||
### When Fork is Used
|
||||
|
||||
@@ -61,7 +61,7 @@ Fork children cannot create further forks. This is enforced at runtime — if a
|
||||
1. **Configuration**: You create Subagents configurations that define their behavior, tools, and system prompts
|
||||
2. **Delegation**: The main AI can automatically delegate tasks to appropriate Subagents — or fork itself (`subagent_type: "fork"`) when it wants to inherit the full conversation context and discard the intermediate output
|
||||
3. **Execution**: Subagents work independently, using their configured tools to complete tasks
|
||||
4. **Results**: They return results and execution summaries back to the main conversation
|
||||
4. **Results**: Background runs notify the main conversation when they finish; foreground opt-outs return results inline
|
||||
|
||||
## Getting Started
|
||||
|
||||
|
||||
@@ -181,8 +181,8 @@ The `agent` tool accepts an optional `isolation: "worktree"` parameter. When set
|
||||
|
||||
Two constraints:
|
||||
|
||||
- `isolation: "worktree"` requires a `subagent_type` — forked sub-agents (no `subagent_type`) reuse the parent's full conversation context, so isolating them would split intent from working tree.
|
||||
- Background agents (`run_in_background: true`) work fine with isolation; the cleanup runs when the agent reports completion.
|
||||
- `isolation: "worktree"` requires a non-fork `subagent_type` — forked sub-agents (`subagent_type: "fork"`) reuse the parent's full conversation context, so isolating them would split intent from working tree.
|
||||
- Agents using `isolation: "worktree"` follow the default background behavior; the cleanup runs when the agent reports completion. Set `run_in_background: false` for an inline result. Caller-owned `working_dir` launches remain foreground by default because their lifecycle is managed externally.
|
||||
|
||||
### Automatic Stale Cleanup
|
||||
|
||||
|
||||
@@ -126,23 +126,29 @@ You are a worker agent processing a batch of files.
|
||||
|
||||
### Example Invocation Pattern
|
||||
|
||||
Set `run_in_background: false` on every worker call so all results return inline
|
||||
for aggregation in Step 4.
|
||||
|
||||
```
|
||||
<Agent tool call 1>
|
||||
description: "Process batch chunk 1/3"
|
||||
prompt: "You are a worker agent... [full prompt as above]"
|
||||
subagent_type: "general-purpose"
|
||||
run_in_background: false
|
||||
</Agent tool call 1>
|
||||
|
||||
<Agent tool call 2>
|
||||
description: "Process batch chunk 2/3"
|
||||
prompt: "You are a worker agent... [full prompt as above]"
|
||||
subagent_type: "general-purpose"
|
||||
run_in_background: false
|
||||
</Agent tool call 2>
|
||||
|
||||
<Agent tool call 3>
|
||||
description: "Process batch chunk 3/3"
|
||||
prompt: "You are a worker agent... [full prompt as above]"
|
||||
subagent_type: "general-purpose"
|
||||
run_in_background: false
|
||||
</Agent tool call 3>
|
||||
```
|
||||
|
||||
|
||||
@@ -380,7 +380,7 @@ A check you perform silently is a check you skip, and this one has been skipped:
|
||||
|
||||
## Agent dimensions (used by 3A and 3B; reused inline by 3C)
|
||||
|
||||
**Every agent MUST be an awaitable subagent: set `subagent_type: "general-purpose"` on every `agent` call.** Do NOT fork them — do not omit `subagent_type`, and never set `subagent_type: "fork"`. A fork runs fire-and-forget and its findings never come back to you, so the review would stall in Step 4 with nothing to aggregate. You need every agent's findings returned to you inline.
|
||||
**Every agent MUST return inline: set `subagent_type: "general-purpose"` and `run_in_background: false` on every `agent` call.** Do NOT fork them — never set `subagent_type: "fork"`. A fork runs fire-and-forget and its findings never come back to you, so the review would stall in Step 4 with nothing to aggregate. You need every agent's findings returned to you inline.
|
||||
|
||||
**For same-repo PR reviews (worktree mode), every `agent` call MUST also set `working_dir: "<worktreePath>"`** — the `worktreePath` from the Step 1 fetch report (a repo-relative path like `.qwen/tmp/review-pr-<n>`; pass it through as-is). This sets each agent's working directory to the PR worktree, so its `git diff`, `grep_search`, file reads, and Agent 7's build/test **resolve against the PR's code, not the user's main checkout**. It is a deterministic, harness-level cwd pin — it does NOT depend on the agent remembering to `cd`, and it is what makes reviewing multiple PRs concurrently safe. (It pins the working directory; it is not a hard filesystem sandbox — an absolute path could still reach elsewhere — but normal review operations stay inside the worktree.) This rule applies to **every** agent the review workflow launches — not just the Step 3 dimension agents, but also the Step 4 verification agent and the Step 5 reverse-audit agents (both restated below). Do NOT set `working_dir` for **local-diff, file-path, or cross-repo lightweight** reviews — those have no worktree, so the agents run in the main project directory.
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ Use `git diff HEAD` whenever staged changes exist. Otherwise use `git diff`.
|
||||
|
||||
## Step 2: Launch three review passes in parallel
|
||||
|
||||
Use the `agent` tool and launch all review passes in a single response so they run concurrently. **Set `subagent_type: "general-purpose"` on every call — each pass must be an awaitable subagent whose findings return to you inline. Do NOT fork them: do not omit `subagent_type`, and never set `subagent_type: "fork"`. A fork runs fire-and-forget and never returns its findings, so there would be nothing to aggregate in Step 3.** Each pass must receive the same review scope and diff command. These passes are read-only: each one inspects and reports findings only and must not modify files — all edits happen later in Step 4.
|
||||
Use the `agent` tool and launch all review passes in a single response so they run concurrently. **Set `subagent_type: "general-purpose"` and `run_in_background: false` on every call — each pass must return its findings inline. Do NOT fork them: never set `subagent_type: "fork"`. A fork runs fire-and-forget and never returns its findings, so there would be nothing to aggregate in Step 3.** Each pass must receive the same review scope and diff command. These passes are read-only: each one inspects and reports findings only and must not modify files — all edits happen later in Step 4.
|
||||
|
||||
Keep each review prompt short and focused. Do not paste the full diff into the prompt. Tell each pass to read the diff itself and inspect only files relevant to its findings.
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { ToolNames } from '../tools/tool-names.js';
|
||||
import { BuiltinAgentRegistry } from './builtin-agents.js';
|
||||
|
||||
describe('BuiltinAgentRegistry', () => {
|
||||
@@ -35,6 +36,21 @@ describe('BuiltinAgentRegistry', () => {
|
||||
|
||||
expect(generalAgent).toBeDefined();
|
||||
expect(generalAgent?.description).toContain('General-purpose agent');
|
||||
expect(generalAgent?.systemPrompt).toContain(
|
||||
'general-purpose subagent working for a parent agent',
|
||||
);
|
||||
expect(generalAgent?.systemPrompt).toContain(
|
||||
'Preserve unrelated user changes',
|
||||
);
|
||||
expect(generalAgent?.systemPrompt).toContain(
|
||||
'Verify factual claims before reporting',
|
||||
);
|
||||
expect(generalAgent?.systemPrompt).toContain(
|
||||
'run the smallest relevant checks',
|
||||
);
|
||||
expect(generalAgent?.systemPrompt).toContain(
|
||||
'Report uncertainty or blockers',
|
||||
);
|
||||
});
|
||||
|
||||
it('should let the Explore agent inherit the main model', () => {
|
||||
@@ -44,6 +60,18 @@ describe('BuiltinAgentRegistry', () => {
|
||||
expect(exploreAgent?.model).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps the Explore agent read-only without banning shell pipelines', () => {
|
||||
const exploreAgent = BuiltinAgentRegistry.getBuiltinAgent('Explore');
|
||||
|
||||
expect(exploreAgent?.tools).not.toContain(ToolNames.TODO_WRITE);
|
||||
expect(exploreAgent?.tools).not.toContain(ToolNames.MEMORY);
|
||||
expect(exploreAgent?.tools).not.toContain(ToolNames.ASK_USER_QUESTION);
|
||||
expect(exploreAgent?.systemPrompt).toContain(
|
||||
'pipelines are allowed when every command is read-only',
|
||||
);
|
||||
expect(exploreAgent?.systemPrompt).not.toContain('(>, >>, |)');
|
||||
});
|
||||
|
||||
// Regression for #7126: Explore is a read-only search worker that
|
||||
// typically runs as a subagent with no human in the loop. An
|
||||
// interactive question tool would block the pipeline forever.
|
||||
|
||||
@@ -28,27 +28,21 @@ export class BuiltinAgentRegistry {
|
||||
name: DEFAULT_BUILTIN_SUBAGENT_TYPE,
|
||||
description:
|
||||
'General-purpose agent for researching complex questions, searching for code, and executing multi-step tasks. When you are searching for a keyword or file and are not confident that you will find the right match in the first few tries use this agent to perform the search for you.',
|
||||
systemPrompt: `You are a general-purpose agent. Given the user's message, you should use the tools available to complete the task. Do what has been asked; nothing more, nothing less. When you complete the task, respond with a concise report covering what was done and any key findings — the caller will relay this to the user, so it only needs the essentials.
|
||||
|
||||
Your strengths:
|
||||
- Searching for code, configurations, and patterns across large codebases
|
||||
- Analyzing multiple files to understand system architecture
|
||||
- Investigating complex questions that require exploring many files
|
||||
- Performing multi-step research tasks
|
||||
systemPrompt: `You are a general-purpose subagent working for a parent agent. Complete only the assigned task, using the available tools as needed. Do not expand the scope or perform adjacent work unless it is necessary to complete the task.
|
||||
|
||||
Guidelines:
|
||||
- Inspect the relevant code and existing state before making changes.
|
||||
- Preserve unrelated user changes.
|
||||
- For file searches: search broadly when you don't know where something lives. Use ${ToolNames.READ_FILE} when you know the specific file path.
|
||||
- For analysis: Start broad and narrow down. Use multiple search strategies if the first doesn't yield results.
|
||||
- Be thorough: Check multiple locations, consider different naming conventions, look for related files.
|
||||
- NEVER create files unless they're absolutely necessary for achieving your goal. ALWAYS prefer editing an existing file to creating a new one.
|
||||
- NEVER proactively create documentation files (*.md) or README files. Only create documentation files if explicitly requested.
|
||||
- In your final response, share file paths (always absolute, never relative) that are relevant to the task. Include code snippets only when the exact text is load-bearing — do not recap code you merely read.
|
||||
- For clear communication, avoid using emojis.
|
||||
- For analysis: start broad and narrow down. Use multiple search strategies if the first doesn't yield results.
|
||||
- Prefer editing existing files. Do not create files unless they are necessary to complete the task. Do not create documentation files (*.md) or README files unless explicitly requested.
|
||||
- Verify factual claims before reporting. When making changes, run the smallest relevant checks.
|
||||
- Do not guess when evidence is unavailable. Report uncertainty or blockers.
|
||||
|
||||
Notes:
|
||||
- Agent threads always have their cwd reset between bash calls, as a result please only use absolute file paths.
|
||||
- In your final response, share file paths (always absolute, never relative) that are relevant to the task. Include code snippets only when the exact text is load-bearing (e.g., a bug you found, a function signature the caller asked for) — do not recap code you merely read.
|
||||
- For clear communication with the user the assistant MUST avoid using emojis.`,
|
||||
- Return a concise report to the parent agent containing, as applicable: the result and key evidence, files changed, verification performed and its outcome, and remaining issues or blockers.
|
||||
- Include code snippets only when the exact text is load-bearing (e.g., a bug you found or a function signature the caller asked for); do not recap code you merely read.`,
|
||||
},
|
||||
{
|
||||
name: 'Explore',
|
||||
@@ -63,7 +57,7 @@ This is a READ-ONLY exploration task. You are STRICTLY PROHIBITED from:
|
||||
- Deleting files (no rm or deletion)
|
||||
- Moving or copying files (no mv or cp)
|
||||
- Creating temporary files anywhere, including /tmp
|
||||
- Using redirect operators (>, >>, |) or heredocs to write to files
|
||||
- Redirecting output to files (>, >>, or writing heredocs); pipelines are allowed when every command is read-only and no command sends data to a network endpoint (no curl, wget, nc, or similar)
|
||||
- Running ANY commands that change system state
|
||||
|
||||
Your role is EXCLUSIVELY to search and analyze existing code. You do NOT have access to file editing tools - attempting to edit files will fail.
|
||||
@@ -101,8 +95,6 @@ Notes:
|
||||
ToolNames.SHELL,
|
||||
ToolNames.LS,
|
||||
ToolNames.WEB_FETCH,
|
||||
ToolNames.TODO_WRITE,
|
||||
ToolNames.MEMORY,
|
||||
ToolNames.SKILL,
|
||||
ToolNames.LSP,
|
||||
// ASK_USER_QUESTION is deliberately absent: Explore is a read-only
|
||||
|
||||
@@ -120,9 +120,8 @@ export interface SubagentConfig {
|
||||
color?: string;
|
||||
|
||||
/**
|
||||
* When true, this agent always runs as a background task when spawned.
|
||||
* OR'd with the `run_in_background` tool parameter — if either is true,
|
||||
* the agent runs in the background.
|
||||
* When true, this agent defaults to a background task when spawned.
|
||||
* An explicit `run_in_background` tool parameter takes precedence.
|
||||
*/
|
||||
background?: boolean;
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ import { SubagentManager } from '../../subagents/subagent-manager.js';
|
||||
import type { SubagentConfig } from '../../subagents/types.js';
|
||||
import { BUBBLE_APPROVAL_MODE } from '../../subagents/types.js';
|
||||
import {
|
||||
buildChildMessage,
|
||||
FORK_AGENT,
|
||||
FORK_DEFAULT_MAX_TURNS,
|
||||
runInForkContext,
|
||||
@@ -348,6 +349,35 @@ describe('AgentTool', () => {
|
||||
expect(tool.description).toContain("Don't race");
|
||||
expect(tool.description).toContain('Writing a fork prompt');
|
||||
});
|
||||
|
||||
it('advertises background execution as the default with a foreground opt-out', async () => {
|
||||
const tool = new AgentTool(config);
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(tool.description).toContain('background by default');
|
||||
expect(tool.description).toContain('run_in_background: false');
|
||||
});
|
||||
|
||||
it('requires bounded delegation and verification of subagent results', async () => {
|
||||
const tool = new AgentTool(config);
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(tool.description).toContain('concrete, bounded tasks');
|
||||
expect(tool.description).toContain('immediate critical-path work local');
|
||||
expect(tool.description).toContain(
|
||||
'Do not duplicate work between the parent and subagents',
|
||||
);
|
||||
expect(tool.description).toContain('disjoint write scopes');
|
||||
expect(tool.description).toContain(
|
||||
"Treat the agent's output as evidence, not as automatically correct",
|
||||
);
|
||||
expect(tool.description).not.toContain(
|
||||
"The agent's outputs should generally be trusted",
|
||||
);
|
||||
expect(tool.description).not.toContain(
|
||||
'Launch multiple agents concurrently whenever possible',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('schema generation', () => {
|
||||
@@ -366,6 +396,22 @@ describe('AgentTool', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('declares the background default and foreground opt-out', () => {
|
||||
const properties = agentTool.schema.parametersJsonSchema as {
|
||||
properties: {
|
||||
run_in_background: {
|
||||
default?: boolean;
|
||||
description?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
expect(properties.properties.run_in_background.default).toBe(true);
|
||||
expect(properties.properties.run_in_background.description).toContain(
|
||||
'Set to false',
|
||||
);
|
||||
});
|
||||
|
||||
it('does not advertise "fork" in the enum, even when interactive', async () => {
|
||||
// `fork` is intentionally omitted from the enum so the model is not
|
||||
// steered to fork result-bearing work; it stays valid via validation.
|
||||
@@ -1211,6 +1257,7 @@ describe('AgentTool', () => {
|
||||
description: 'Search files',
|
||||
prompt: 'Find all TypeScript files',
|
||||
subagent_type: 'file-search',
|
||||
run_in_background: false,
|
||||
};
|
||||
|
||||
const invocation = (
|
||||
@@ -1332,6 +1379,7 @@ describe('AgentTool', () => {
|
||||
description: 'Search files',
|
||||
prompt: 'Find all TypeScript files',
|
||||
subagent_type: 'file-search',
|
||||
run_in_background: false,
|
||||
};
|
||||
|
||||
const invocation = (
|
||||
@@ -1358,6 +1406,7 @@ describe('AgentTool', () => {
|
||||
description: 'Search files',
|
||||
prompt: 'Find all TypeScript files',
|
||||
subagent_type: 'file-search',
|
||||
run_in_background: false,
|
||||
};
|
||||
|
||||
const invocation = (
|
||||
@@ -1377,6 +1426,7 @@ describe('AgentTool', () => {
|
||||
description: 'Search files',
|
||||
prompt: 'Find all TypeScript files',
|
||||
subagent_type: 'file-search',
|
||||
run_in_background: false,
|
||||
};
|
||||
|
||||
const invocation = (
|
||||
@@ -1525,6 +1575,66 @@ describe('AgentTool', () => {
|
||||
}
|
||||
}, 20000);
|
||||
|
||||
it('keeps a working_dir launch in the foreground when the flag is omitted', async () => {
|
||||
// The default-background rule excludes caller-owned worktree launches
|
||||
// (`this.params.working_dir === undefined` in backgroundRequested). Guard
|
||||
// the core dispatch directly so a future refactor that drops the
|
||||
// working_dir exclusion is caught here, not only in the UI classifiers.
|
||||
vi.useRealTimers();
|
||||
const repo = fs.realpathSync(
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), 'qwen-agent-wd-fg-')),
|
||||
);
|
||||
try {
|
||||
execFileSync('git', ['init', '-q', '-b', 'main'], { cwd: repo });
|
||||
execFileSync('git', ['config', 'user.email', '[email protected]'], { cwd: repo });
|
||||
execFileSync('git', ['config', 'user.name', 't'], { cwd: repo });
|
||||
execFileSync('git', ['config', 'commit.gpgsign', 'false'], {
|
||||
cwd: repo,
|
||||
});
|
||||
fs.writeFileSync(path.join(repo, 'README.md'), 'hi\n');
|
||||
execFileSync('git', ['add', '.'], { cwd: repo });
|
||||
execFileSync('git', ['commit', '-q', '-m', 'init', '--no-verify'], {
|
||||
cwd: repo,
|
||||
});
|
||||
|
||||
const wt = path.join(repo, '.qwen', 'tmp', 'review-pr-1');
|
||||
fs.mkdirSync(path.dirname(wt), { recursive: true });
|
||||
execFileSync(
|
||||
'git',
|
||||
['worktree', 'add', '-b', 'review-pr-1', wt, 'HEAD'],
|
||||
{ cwd: repo },
|
||||
);
|
||||
|
||||
vi.mocked(config.getProjectRoot).mockReturnValue(repo);
|
||||
vi.mocked(config.getTargetDir).mockReturnValue(repo);
|
||||
vi.mocked(config.getCwd).mockReturnValue(repo);
|
||||
vi.mocked(config.getWorkingDir).mockReturnValue(repo);
|
||||
|
||||
const invocation = (
|
||||
agentTool as AgentToolWithProtectedMethods
|
||||
).createInvocation({
|
||||
description: 'Review',
|
||||
prompt: 'Review the diff',
|
||||
subagent_type: 'file-search',
|
||||
working_dir: wt,
|
||||
// run_in_background intentionally omitted.
|
||||
});
|
||||
const result = await invocation.execute();
|
||||
|
||||
// Foreground: the omitted flag must NOT route a caller-owned worktree
|
||||
// launch through the background registry path.
|
||||
expect(partToString(result.llmContent)).not.toContain(
|
||||
'Background agent launched',
|
||||
);
|
||||
expect(
|
||||
vi.mocked(mockSubagentManager.createAgentHeadless),
|
||||
).toHaveBeenCalled();
|
||||
} finally {
|
||||
fs.rmSync(repo, { recursive: true, force: true });
|
||||
vi.useFakeTimers();
|
||||
}
|
||||
}, 20000);
|
||||
|
||||
it('rejects working_dir that is not a registered worktree of this repo', async () => {
|
||||
vi.useRealTimers();
|
||||
const repo = fs.realpathSync(
|
||||
@@ -1969,6 +2079,7 @@ describe('AgentTool', () => {
|
||||
description: 'Search files',
|
||||
prompt: 'Find all TypeScript files',
|
||||
subagent_type: 'file-search',
|
||||
run_in_background: false,
|
||||
};
|
||||
|
||||
const invocation = (
|
||||
@@ -1988,6 +2099,7 @@ describe('AgentTool', () => {
|
||||
description: 'Search files',
|
||||
prompt: 'Find all TypeScript files',
|
||||
subagent_type: 'file-search',
|
||||
run_in_background: false,
|
||||
};
|
||||
|
||||
const invocation = (
|
||||
@@ -2030,6 +2142,7 @@ describe('AgentTool', () => {
|
||||
description: 'Search files',
|
||||
prompt: 'Find all TypeScript files',
|
||||
subagent_type: 'file-search',
|
||||
run_in_background: false,
|
||||
};
|
||||
|
||||
const invocation = (
|
||||
@@ -2097,6 +2210,7 @@ describe('AgentTool', () => {
|
||||
description: 'Search files',
|
||||
prompt: 'Find all TypeScript files',
|
||||
subagent_type: 'file-search',
|
||||
run_in_background: false,
|
||||
};
|
||||
const invocation = (
|
||||
agentTool as AgentToolWithProtectedMethods
|
||||
@@ -2248,6 +2362,7 @@ describe('AgentTool', () => {
|
||||
description: 'Search files',
|
||||
prompt: 'Find all TypeScript files',
|
||||
subagent_type: 'file-search',
|
||||
run_in_background: false,
|
||||
};
|
||||
const invocation = (
|
||||
agentTool as AgentToolWithProtectedMethods
|
||||
@@ -2296,6 +2411,7 @@ describe('AgentTool', () => {
|
||||
description: 'Search files',
|
||||
prompt: 'Find all TypeScript files',
|
||||
subagent_type: 'file-search',
|
||||
run_in_background: false,
|
||||
};
|
||||
const invocation = (
|
||||
agentTool as AgentToolWithProtectedMethods
|
||||
@@ -2319,6 +2435,7 @@ describe('AgentTool', () => {
|
||||
description: 'Search files',
|
||||
prompt: 'Find all TypeScript files',
|
||||
subagent_type: 'file-search',
|
||||
run_in_background: false,
|
||||
};
|
||||
const invocation = (
|
||||
agentTool as AgentToolWithProtectedMethods
|
||||
@@ -2391,6 +2508,20 @@ describe('AgentTool', () => {
|
||||
.mockReturnValue(true);
|
||||
});
|
||||
|
||||
it('does not require a commit unless the directive asks for one', () => {
|
||||
const childMessage = buildChildMessage('update the implementation');
|
||||
|
||||
expect(childMessage).toContain(
|
||||
'Do NOT create a commit unless the directive explicitly asks you to',
|
||||
);
|
||||
expect(childMessage).toContain(
|
||||
'Verification: <checks performed and their outcome',
|
||||
);
|
||||
expect(childMessage).not.toContain(
|
||||
'commit your changes before reporting',
|
||||
);
|
||||
});
|
||||
|
||||
it('forks in interactive mode', async () => {
|
||||
const mockLoadedSubagent: SubagentConfig = {
|
||||
name: 'general-purpose',
|
||||
@@ -2452,8 +2583,8 @@ describe('AgentTool', () => {
|
||||
});
|
||||
|
||||
it('omitting subagent_type uses general-purpose, not fork', async () => {
|
||||
// Restored contract: omission resolves to the awaitable general-purpose
|
||||
// subagent (inline result), never a fork — even in interactive mode.
|
||||
// Omission resolves to the regular general-purpose subagent, never a
|
||||
// context-inheriting fork — even in interactive mode.
|
||||
const mockLoadedSubagent: SubagentConfig = {
|
||||
name: 'general-purpose',
|
||||
description: 'General-purpose agent',
|
||||
@@ -2875,6 +3006,7 @@ describe('AgentTool', () => {
|
||||
description: 'Search files',
|
||||
prompt: 'Find all TypeScript files',
|
||||
subagent_type: 'file-search',
|
||||
run_in_background: false,
|
||||
};
|
||||
|
||||
const invocation = (
|
||||
@@ -2907,6 +3039,7 @@ describe('AgentTool', () => {
|
||||
description: 'Search files',
|
||||
prompt: 'Find all TypeScript files',
|
||||
subagent_type: 'file-search',
|
||||
run_in_background: false,
|
||||
};
|
||||
|
||||
const invocation = (
|
||||
@@ -2932,6 +3065,7 @@ describe('AgentTool', () => {
|
||||
description: 'Search files',
|
||||
prompt: 'Find all TypeScript files',
|
||||
subagent_type: 'file-search',
|
||||
run_in_background: false,
|
||||
};
|
||||
|
||||
const invocation = (
|
||||
@@ -2957,6 +3091,7 @@ describe('AgentTool', () => {
|
||||
description: 'Search files',
|
||||
prompt: 'Find all TypeScript files',
|
||||
subagent_type: 'file-search',
|
||||
run_in_background: false,
|
||||
};
|
||||
|
||||
const invocation = (
|
||||
@@ -2980,6 +3115,7 @@ describe('AgentTool', () => {
|
||||
description: 'Search files',
|
||||
prompt: 'Find all TypeScript files',
|
||||
subagent_type: 'file-search',
|
||||
run_in_background: false,
|
||||
};
|
||||
|
||||
const invocation = (
|
||||
@@ -3066,6 +3202,7 @@ describe('AgentTool', () => {
|
||||
description: 'Search files',
|
||||
prompt: 'Find all TypeScript files',
|
||||
subagent_type: 'file-search',
|
||||
run_in_background: false,
|
||||
};
|
||||
|
||||
const invocation = (
|
||||
@@ -3106,6 +3243,7 @@ describe('AgentTool', () => {
|
||||
description: 'Search files',
|
||||
prompt: 'Find all TypeScript files',
|
||||
subagent_type: 'file-search',
|
||||
run_in_background: false,
|
||||
};
|
||||
|
||||
const invocation = (
|
||||
@@ -3146,6 +3284,7 @@ describe('AgentTool', () => {
|
||||
description: 'Search files',
|
||||
prompt: 'Find all TypeScript files',
|
||||
subagent_type: 'file-search',
|
||||
run_in_background: false,
|
||||
};
|
||||
|
||||
const invocation = (
|
||||
@@ -3176,6 +3315,7 @@ describe('AgentTool', () => {
|
||||
description: 'Search files',
|
||||
prompt: 'Find all TypeScript files',
|
||||
subagent_type: 'file-search',
|
||||
run_in_background: false,
|
||||
};
|
||||
|
||||
const invocation = (
|
||||
@@ -3199,6 +3339,7 @@ describe('AgentTool', () => {
|
||||
description: 'Search files',
|
||||
prompt: 'Find all TypeScript files',
|
||||
subagent_type: 'file-search',
|
||||
run_in_background: false,
|
||||
};
|
||||
|
||||
const invocation = (
|
||||
@@ -3221,6 +3362,7 @@ describe('AgentTool', () => {
|
||||
description: 'Search files',
|
||||
prompt: 'Find all TypeScript files',
|
||||
subagent_type: 'file-search',
|
||||
run_in_background: false,
|
||||
};
|
||||
|
||||
const invocation = (
|
||||
@@ -3256,6 +3398,7 @@ describe('AgentTool', () => {
|
||||
description: 'Search files',
|
||||
prompt: 'Find all TypeScript files',
|
||||
subagent_type: 'file-search',
|
||||
run_in_background: false,
|
||||
};
|
||||
|
||||
const invocation = (
|
||||
@@ -3287,6 +3430,7 @@ describe('AgentTool', () => {
|
||||
description: 'Search files',
|
||||
prompt: 'Find all TypeScript files',
|
||||
subagent_type: 'file-search',
|
||||
run_in_background: false,
|
||||
};
|
||||
|
||||
const invocation = (
|
||||
@@ -3302,6 +3446,7 @@ describe('AgentTool', () => {
|
||||
description: 'Search files',
|
||||
prompt: 'Find all TypeScript files',
|
||||
subagent_type: 'file-search',
|
||||
run_in_background: false,
|
||||
};
|
||||
|
||||
const invocation = (
|
||||
@@ -3388,6 +3533,7 @@ describe('AgentTool', () => {
|
||||
description: 'Edit files',
|
||||
prompt: 'Fix the bug',
|
||||
subagent_type: 'file-search',
|
||||
run_in_background: false,
|
||||
};
|
||||
|
||||
capturedInvocation = (
|
||||
@@ -4015,6 +4161,115 @@ describe('AgentTool', () => {
|
||||
expect(mockRegistry.register).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('runs a top-level subagent in the background when the flag is omitted', async () => {
|
||||
const defaultSubagent: SubagentConfig = {
|
||||
...bgSubagent,
|
||||
name: 'file-search',
|
||||
background: undefined,
|
||||
};
|
||||
vi.mocked(mockSubagentManager.loadSubagent).mockResolvedValue(
|
||||
defaultSubagent,
|
||||
);
|
||||
|
||||
const invocation = (
|
||||
agentTool as AgentToolWithProtectedMethods
|
||||
).createInvocation({
|
||||
description: 'Search files',
|
||||
prompt: 'Find all TypeScript files',
|
||||
subagent_type: 'file-search',
|
||||
});
|
||||
const result = await invocation.execute();
|
||||
|
||||
expect(partToString(result.llmContent)).toContain(
|
||||
'Background agent launched',
|
||||
);
|
||||
expect(mockRegistry.register).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps a named-teammate launch in the foreground when no team is active and the flag is omitted', async () => {
|
||||
// A `name` passed without an active team manager falls through to a
|
||||
// regular one-shot agent (agent.ts logs and continues), and
|
||||
// backgroundRequested excludes `name` (`this.params.name === undefined`).
|
||||
// Guard the core dispatch directly so a future refactor that drops the
|
||||
// `name` exclusion is caught here, not only in the UI classifiers (both
|
||||
// of which treat named calls as foreground).
|
||||
vi.mocked(config.getTeamManager).mockReturnValue(null);
|
||||
const defaultSubagent: SubagentConfig = {
|
||||
...bgSubagent,
|
||||
name: 'file-search',
|
||||
background: undefined,
|
||||
};
|
||||
vi.mocked(mockSubagentManager.loadSubagent).mockResolvedValue(
|
||||
defaultSubagent,
|
||||
);
|
||||
|
||||
const invocation = (
|
||||
agentTool as AgentToolWithProtectedMethods
|
||||
).createInvocation({
|
||||
description: 'Review the diff',
|
||||
prompt: 'Review the diff',
|
||||
subagent_type: 'file-search',
|
||||
name: 'reviewer',
|
||||
// run_in_background intentionally omitted.
|
||||
});
|
||||
const result = await invocation.execute();
|
||||
|
||||
// Foreground: an omitted flag on a named launch must NOT route through
|
||||
// the background registry path.
|
||||
expect(partToString(result.llmContent)).not.toContain(
|
||||
'Background agent launched',
|
||||
);
|
||||
expect(mockRegistry.register).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ isBackgrounded: false }),
|
||||
);
|
||||
});
|
||||
|
||||
it('runs in the foreground when run_in_background is false', async () => {
|
||||
const invocation = (
|
||||
agentTool as AgentToolWithProtectedMethods
|
||||
).createInvocation({
|
||||
description: 'Start monitor',
|
||||
prompt: 'Watch for changes',
|
||||
subagent_type: 'monitor',
|
||||
run_in_background: false,
|
||||
});
|
||||
const result = await invocation.execute();
|
||||
|
||||
expect(partToString(result.llmContent)).toBe('Monitor done');
|
||||
expect(mockRegistry.register).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ isBackgrounded: false }),
|
||||
);
|
||||
});
|
||||
|
||||
it('lets an explicit run_in_background: false override a config with background: true', async () => {
|
||||
// Precedence contract: the explicit tool parameter wins over the
|
||||
// subagent config's background flag (`run_in_background ?? config`).
|
||||
// A `||` here would let background: true override the explicit false
|
||||
// and detach the agent when the caller asked for an inline result.
|
||||
const explicitBackgroundConfig: SubagentConfig = {
|
||||
...bgSubagent,
|
||||
name: 'file-search',
|
||||
};
|
||||
vi.mocked(mockSubagentManager.loadSubagent).mockResolvedValue(
|
||||
explicitBackgroundConfig,
|
||||
);
|
||||
|
||||
const invocation = (
|
||||
agentTool as AgentToolWithProtectedMethods
|
||||
).createInvocation({
|
||||
description: 'Search files',
|
||||
prompt: 'Find all TypeScript files',
|
||||
subagent_type: 'file-search',
|
||||
run_in_background: false,
|
||||
});
|
||||
const result = await invocation.execute();
|
||||
|
||||
expect(partToString(result.llmContent)).toBe('Monitor done');
|
||||
expect(mockRegistry.register).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ isBackgrounded: false }),
|
||||
);
|
||||
});
|
||||
|
||||
it('downgrades a background request from a nested sub-agent to an awaited foreground run', async () => {
|
||||
// Background delegation is top-level-only in v1: a nested launcher
|
||||
// cannot honor the background completion contract (send_message /
|
||||
@@ -4044,6 +4299,27 @@ describe('AgentTool', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps an omitted background flag in the foreground for nested sub-agents', async () => {
|
||||
vi.mocked(config.getMaxSubagentDepth).mockReturnValue(5);
|
||||
const invocation = (
|
||||
agentTool as AgentToolWithProtectedMethods
|
||||
).createInvocation({
|
||||
description: 'Search from a nested sub-agent',
|
||||
prompt: 'Find all TypeScript files',
|
||||
subagent_type: 'file-search',
|
||||
});
|
||||
|
||||
const result = await runWithAgentContext('sub-1', () =>
|
||||
invocation.execute(),
|
||||
);
|
||||
|
||||
expect(partToString(result.llmContent)).toBe('Monitor done');
|
||||
expect(mockRegistry.register).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ isBackgrounded: false }),
|
||||
);
|
||||
expect(mockRegistry.tryReserveBackgroundSlot).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns registry registration errors to the model without launching the background body', async () => {
|
||||
const errorMessage =
|
||||
'Cannot start background agent: maximum concurrent background agents ' +
|
||||
@@ -4228,7 +4504,7 @@ describe('AgentTool', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should run in foreground when neither flag is set', async () => {
|
||||
it('should run in foreground when run_in_background is false', async () => {
|
||||
const fgSubagent: SubagentConfig = {
|
||||
...bgSubagent,
|
||||
name: 'file-search',
|
||||
@@ -4240,6 +4516,7 @@ describe('AgentTool', () => {
|
||||
description: 'Search files',
|
||||
prompt: 'Find all TypeScript files',
|
||||
subagent_type: 'file-search',
|
||||
run_in_background: false,
|
||||
};
|
||||
|
||||
const invocation = (
|
||||
@@ -4306,6 +4583,7 @@ describe('AgentTool', () => {
|
||||
description: 'Search files',
|
||||
prompt: 'Find all TypeScript files',
|
||||
subagent_type: 'file-search',
|
||||
run_in_background: false,
|
||||
});
|
||||
await invocation.execute();
|
||||
|
||||
@@ -4335,6 +4613,7 @@ describe('AgentTool', () => {
|
||||
description: 'Search files',
|
||||
prompt: 'Find all TypeScript files',
|
||||
subagent_type: 'file-search',
|
||||
run_in_background: false,
|
||||
};
|
||||
|
||||
const invocation = (
|
||||
@@ -4413,6 +4692,7 @@ describe('AgentTool', () => {
|
||||
description: 'Search files',
|
||||
prompt: 'Find all TypeScript files',
|
||||
subagent_type: 'file-search',
|
||||
run_in_background: false,
|
||||
};
|
||||
|
||||
const invocation = (
|
||||
@@ -4498,6 +4778,7 @@ describe('AgentTool', () => {
|
||||
description: 'Search files',
|
||||
prompt: 'Find all TypeScript files',
|
||||
subagent_type: 'file-search',
|
||||
run_in_background: false,
|
||||
};
|
||||
|
||||
const invocation = (
|
||||
@@ -4537,6 +4818,7 @@ describe('AgentTool', () => {
|
||||
description: 'Search files',
|
||||
prompt: 'Find all TypeScript files',
|
||||
subagent_type: 'file-search',
|
||||
run_in_background: false,
|
||||
};
|
||||
|
||||
const invocation = (
|
||||
|
||||
@@ -756,8 +756,9 @@ export class AgentTool extends BaseDeclarativeTool<AgentParams, ToolResult> {
|
||||
},
|
||||
run_in_background: {
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
description:
|
||||
'Set to true to run this agent in the background. You will be notified when it completes. Top-level session only: from within a sub-agent the task runs in the foreground and returns its result inline.',
|
||||
'Defaults to true for top-level one-shot agents. Set to false to run in the foreground and return the result inline. Nested agents run in the foreground. Caller-owned working_dir launches default to foreground and cannot run in the background.',
|
||||
},
|
||||
...(config.isAgentTeamEnabled()
|
||||
? {
|
||||
@@ -855,8 +856,8 @@ ${subagentDescriptions}
|
||||
|
||||
${
|
||||
isForkSubagentEnabled(this.config)
|
||||
? `When using the Agent tool, specify a subagent_type to select which agent type to use. If omitted, the general-purpose agent is used and returns its result to you inline. A fork (\`subagent_type: "fork"\`) runs detached and fire-and-forget — its result does NOT come back to you, so use it ONLY for work whose output you won't need. When you need the agent's findings back (review, audit, aggregation, verification), use a regular subagent, never a fork.`
|
||||
: `When using the Agent tool, specify a subagent_type parameter to select which agent type to use. If omitted, the general-purpose agent is used.`
|
||||
? `When using the Agent tool, specify a subagent_type to select which agent type to use. If omitted, the general-purpose agent is used. Top-level one-shot agents run in the background by default and report their results through a completion notification; set \`run_in_background: false\` when you need the result inline before continuing. A fork (\`subagent_type: "fork"\`) runs detached and fire-and-forget — its result does NOT come back to you, so use it ONLY for work whose output you won't need. When you need the agent's findings back (review, audit, aggregation, verification), use a regular subagent, never a fork.`
|
||||
: `When using the Agent tool, specify a subagent_type parameter to select which agent type to use. If omitted, the general-purpose agent is used. Top-level one-shot agents run in the background by default and report their results through a completion notification; set \`run_in_background: false\` when you need the result inline before continuing.`
|
||||
}
|
||||
|
||||
When NOT to use the Agent tool:
|
||||
@@ -869,21 +870,25 @@ ${teamGuidance}
|
||||
|
||||
Usage notes:
|
||||
- Always include a short description (3-5 words) summarizing what the agent will do
|
||||
- Launch multiple agents concurrently whenever possible, to maximize performance; to do that, use a single message with multiple tool uses
|
||||
- When the agent is done, it will return a single message back to you. The result returned by the agent is not visible to the user. To show the user the result, you should send a text message back to the user with a concise summary of the result.
|
||||
- Delegate only concrete, bounded tasks that can run independently.
|
||||
- Keep immediate critical-path work local when your next action depends on it.
|
||||
- Do not duplicate work between the parent and subagents.
|
||||
- Run agents concurrently only when their tasks are independent. For code changes, give concurrent agents disjoint write scopes; launch them in a single message with multiple tool uses.
|
||||
- A background agent reports its result through a completion notification in a later turn. A foreground agent returns its result inline. Agent results are not visible to the user, so relay the relevant outcome in your response.
|
||||
- While background agents run, continue meaningful non-overlapping work. Wait for an agent only when its result blocks the next required step.
|
||||
- Provide clear, detailed prompts so the agent can work autonomously and return exactly the information you need.
|
||||
- The agent's outputs should generally be trusted
|
||||
- Treat the agent's output as evidence, not as automatically correct. Verify factual claims, review code changes, and run relevant checks before integrating or relaying the result.
|
||||
- Clearly tell the agent whether you expect it to write code or just to do research (search, file reads, web fetches, etc.), since it is not aware of the user's intent
|
||||
- If the agent description mentions that it should be used proactively, then you should try your best to use it without the user having to ask for it first. Use your judgement.
|
||||
- If the user specifies that they want you to run agents "in parallel", you MUST send a single message with multiple Agent tool use content blocks. For example, if you need to launch both a build-validator agent and a test-runner agent in parallel, send a single message with both tool calls.
|
||||
- You can optionally set \`run_in_background: true\` to run the agent in the background. You will be notified when it completes. Use this when you have genuinely independent work to do in parallel and don't need the agent's results before you can proceed.
|
||||
- If the user asks for agents "in parallel", group independent launches in a single message with multiple Agent tool use content blocks. Do not parallelize overlapping code changes.
|
||||
- Top-level one-shot agents run in the background by default. Set \`run_in_background: false\` when the current turn must wait for the result before continuing. Nested agents run in the foreground. Caller-owned \`working_dir\` launches default to foreground and cannot run in the background.
|
||||
- You can optionally set \`isolation: "worktree"\` to run the agent in a temporary git worktree, giving it an isolated copy of the repository. The worktree is automatically cleaned up if the agent makes no changes; if changes are made, the worktree path and branch are returned in the result so you can review or merge them.
|
||||
${
|
||||
isForkSubagentEnabled(this.config)
|
||||
? `
|
||||
## When to fork
|
||||
|
||||
A fork (\`subagent_type: "fork"\`) runs detached and fire-and-forget: it inherits your full context, but its findings do NOT come back to you in a form you can act on. **Never fork work whose output you need** — reviews, audits, parallel investigations you must aggregate, verification, anything where you have to read or combine the results. For all of that, launch regular awaitable subagents instead (omit \`subagent_type\` for general-purpose, or name a specific type); each returns its result to you inline, and several in one message still run concurrently. Omitting \`subagent_type\` does NOT fork.
|
||||
A fork (\`subagent_type: "fork"\`) runs detached and fire-and-forget: it inherits your full context, but its findings do NOT come back to you in a form you can act on. **Never fork work whose output you need** — reviews, audits, parallel investigations you must aggregate, verification, anything where you have to read or combine the results. For all of that, launch regular subagents instead (omit \`subagent_type\` for general-purpose, or name a specific type). Regular top-level subagents report through background completion notifications by default; set \`run_in_background: false\` when you need the result inline in the current turn. Omitting \`subagent_type\` does NOT fork.
|
||||
|
||||
Fork only when you genuinely won't need the result back — a detached background chore the user asked you to kick off and move on from. The criterion is qualitative: "will I need to read this output?" If yes, don't fork.
|
||||
|
||||
@@ -2254,10 +2259,9 @@ class AgentToolInvocation extends BaseToolInvocation<AgentParams, ToolResult> {
|
||||
try {
|
||||
// Forking is explicit: `subagent_type: "fork"` selects a fork, and only
|
||||
// when forking is available (interactive session). Any other value — or
|
||||
// an omitted subagent_type — resolves to a real, awaitable subagent
|
||||
// (general-purpose by default) whose result is returned inline. This
|
||||
// keeps the long-standing "omit ⇒ awaitable general-purpose" contract
|
||||
// that skills and callers depend on; a fork is opt-in, never the default.
|
||||
// an omitted subagent_type — resolves to a regular subagent
|
||||
// (general-purpose by default). Its execution mode is decided separately
|
||||
// below; a fork is opt-in, never the default.
|
||||
const requestedType = this.params.subagent_type;
|
||||
const isForkRequested =
|
||||
requestedType?.toLowerCase() === FORK_SUBAGENT_TYPE;
|
||||
@@ -2266,15 +2270,14 @@ class AgentToolInvocation extends BaseToolInvocation<AgentParams, ToolResult> {
|
||||
isForkSubagentEnabled(this.config) &&
|
||||
// v1: fork is a top-level-only capability. A sub-agent — which may now
|
||||
// carry the AgentTool via nesting — that requests a fork falls back to
|
||||
// the awaitable general-purpose sub-agent (via effectiveSubagentType
|
||||
// below) instead of opening a nested fork. Fork nesting is deferred
|
||||
// past v1.
|
||||
// a general-purpose sub-agent (via effectiveSubagentType below) instead
|
||||
// of opening a nested fork. Fork nesting is deferred past v1.
|
||||
isTopLevelSession();
|
||||
const effectiveSubagentType = isFork
|
||||
? undefined
|
||||
: isForkRequested
|
||||
? // Explicit fork requested but unavailable (non-interactive) →
|
||||
// fall back to the awaitable general-purpose subagent.
|
||||
// fall back to the general-purpose subagent.
|
||||
DEFAULT_BUILTIN_SUBAGENT_TYPE
|
||||
: (requestedType ?? DEFAULT_BUILTIN_SUBAGENT_TYPE);
|
||||
if (isForkRequested && !isFork) {
|
||||
@@ -2338,7 +2341,20 @@ class AgentToolInvocation extends BaseToolInvocation<AgentParams, ToolResult> {
|
||||
updateOutput(this.currentDisplay);
|
||||
}
|
||||
|
||||
// OR the tool parameter with the agent definition's background flag.
|
||||
// An explicit tool parameter wins. Otherwise, an agent-level background
|
||||
// flag retains its existing meaning, and safe ordinary one-shot launches
|
||||
// default to background. Caller-owned working_dir launches stay
|
||||
// foreground unless explicitly configured for background, which the
|
||||
// incompatibility guard below rejects. Unavailable fork requests retain
|
||||
// their existing foreground general-purpose fallback.
|
||||
//
|
||||
// This is the source of truth for the background-classification rule. Two
|
||||
// UI classifiers replicate it from tool-call args (they cannot see
|
||||
// subagentConfig.background) and must be kept in sync when it changes:
|
||||
// - packages/web-shell/client/adapters/toolClassification.ts
|
||||
// (isBackgroundSubAgentToolCall)
|
||||
// - packages/desktop/packages/shared/src/agent/tool-matching.ts
|
||||
// (detectBackgroundEvents)
|
||||
//
|
||||
// Background delegation is top-level-only in v1, mirroring the fork
|
||||
// downgrade above: a nested launcher would be handed a completion
|
||||
@@ -2350,8 +2366,14 @@ class AgentToolInvocation extends BaseToolInvocation<AgentParams, ToolResult> {
|
||||
// Downgrade to an awaited foreground run instead of orphaning the
|
||||
// child's results.
|
||||
const backgroundRequested =
|
||||
this.params.run_in_background === true ||
|
||||
subagentConfig.background === true;
|
||||
this.params.run_in_background ??
|
||||
(subagentConfig.background === true ||
|
||||
(!isForkRequested &&
|
||||
this.params.working_dir === undefined &&
|
||||
// A `name` passed without an active team falls through to a regular
|
||||
// one-shot agent above; keep it foreground so both UI classifiers
|
||||
// (which exclude `name`) stay consistent with core dispatch.
|
||||
this.params.name === undefined));
|
||||
const shouldRunInBackground = backgroundRequested && isTopLevelSession();
|
||||
if (this.params.working_dir !== undefined && shouldRunInBackground) {
|
||||
// A caller-owned worktree has no lifecycle coupling to a backgrounded
|
||||
|
||||
@@ -15,10 +15,10 @@ export const FORK_SUBAGENT_TYPE = 'fork';
|
||||
*
|
||||
* Forking is an explicit choice — the caller selects it with
|
||||
* `subagent_type: "fork"`. Omitting `subagent_type` always resolves to the
|
||||
* general-purpose subagent (awaitable, returns its result inline), never a
|
||||
* fork. This preserves the long-standing "omit ⇒ awaitable subagent" contract
|
||||
* that skills and callers depend on. When fork is unavailable, an explicit
|
||||
* `subagent_type: "fork"` also falls back to the general-purpose subagent.
|
||||
* general-purpose subagent, never a fork. Regular top-level subagents run in
|
||||
* the background by default; callers can set `run_in_background: false` for an
|
||||
* inline result. When fork is unavailable, an explicit `subagent_type: "fork"`
|
||||
* falls back to the general-purpose subagent.
|
||||
*/
|
||||
export function isForkSubagentEnabled(config: Config): boolean {
|
||||
return config.isInteractive();
|
||||
@@ -208,7 +208,7 @@ RULES (non-negotiable):
|
||||
2. Do NOT converse, ask questions, or suggest next steps
|
||||
3. Do NOT editorialize or add meta-commentary
|
||||
4. USE your tools directly: Bash, Read, Write, etc.
|
||||
5. If you modify files, commit your changes before reporting. Include the commit hash in your report.
|
||||
5. If you modify files, report the files changed and verification performed. Do NOT create a commit unless the directive explicitly asks you to.
|
||||
6. Do NOT emit text between tool calls. Use tools silently, then report once at the end.
|
||||
7. Stay strictly within your directive's scope. If you discover related systems outside your scope, mention them in one sentence at most — other workers cover those areas.
|
||||
8. Keep your report under 500 words unless the directive specifies otherwise. Be factual and concise.
|
||||
@@ -219,7 +219,8 @@ Output format (plain text labels, not markdown headers):
|
||||
Scope: <echo back your assigned scope in one sentence>
|
||||
Result: <the answer or key findings, limited to the scope above>
|
||||
Key files: <relevant file paths — include for research tasks>
|
||||
Files changed: <list with commit hash — include only if you modified files>
|
||||
Files changed: <list — include only if you modified files>
|
||||
Verification: <checks performed and their outcome — include only if you modified files>
|
||||
Issues: <list — include only if there are issues to flag>
|
||||
</${FORK_BOILERPLATE_TAG}>
|
||||
|
||||
|
||||
@@ -660,10 +660,30 @@ describe('extractToolResults', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('does NOT emit task_backgrounded for foreground Agent with agentId in result', () => {
|
||||
// Foreground Agent tool (no run_in_background) — agentId in result should be ignored
|
||||
it('detects background Agent by default when the flag is omitted', () => {
|
||||
toolIndex.register('toolu_agent', 'Agent', { _intent: 'Explore codebase', prompt: 'Find auth code' })
|
||||
|
||||
const blocks: ContentBlock[] = [
|
||||
makeToolResultBlock('toolu_agent', 'Background agent launched.\nagentId: default_bg_xyz'),
|
||||
]
|
||||
|
||||
const events = extractToolResults(blocks, null, undefined, toolIndex)
|
||||
|
||||
expect(events).toHaveLength(2)
|
||||
expect(events[1]).toMatchObject({
|
||||
type: 'task_backgrounded',
|
||||
toolUseId: 'toolu_agent',
|
||||
taskId: 'default_bg_xyz',
|
||||
})
|
||||
})
|
||||
|
||||
it('does NOT emit task_backgrounded for an explicit foreground Agent with agentId in result', () => {
|
||||
toolIndex.register('toolu_agent', 'Agent', {
|
||||
_intent: 'Explore codebase',
|
||||
prompt: 'Find auth code',
|
||||
run_in_background: false,
|
||||
})
|
||||
|
||||
const blocks: ContentBlock[] = [
|
||||
makeToolResultBlock('toolu_agent', 'Found auth in /src/auth.ts\nagentId: fg_agent_xyz'),
|
||||
]
|
||||
@@ -675,6 +695,86 @@ describe('extractToolResults', () => {
|
||||
expect(events[0]).toMatchObject({ type: 'tool_result', toolUseId: 'toolu_agent' })
|
||||
})
|
||||
|
||||
it('does NOT emit task_backgrounded for an omitted-flag fork Agent', () => {
|
||||
// Mirrors core's `!isForkRequested` guard: a `subagent_type: "fork"`
|
||||
// fallback stays foreground, so no background event even with an agentId
|
||||
// in the result.
|
||||
toolIndex.register('toolu_fork_agent', 'Agent', {
|
||||
_intent: 'Handle a detached chore',
|
||||
prompt: 'Handle the detached chore',
|
||||
subagent_type: 'fork',
|
||||
})
|
||||
|
||||
const blocks: ContentBlock[] = [
|
||||
makeToolResultBlock('toolu_fork_agent', 'Done.\nagentId: fork_agent_xyz'),
|
||||
]
|
||||
|
||||
const events = extractToolResults(blocks, null, undefined, toolIndex)
|
||||
|
||||
expect(events).toHaveLength(1)
|
||||
expect(events[0]).toMatchObject({
|
||||
type: 'tool_result',
|
||||
toolUseId: 'toolu_fork_agent',
|
||||
})
|
||||
})
|
||||
|
||||
it('does NOT emit task_backgrounded for a named teammate Agent', () => {
|
||||
// Mirrors core dispatch: a top-level Agent with `name` set routes to a
|
||||
// named teammate and stays foreground (the classifier excludes
|
||||
// `name !== undefined`), so no background event even with an agentId in the
|
||||
// result. Parallels the web-shell classifier's named-teammate coverage.
|
||||
toolIndex.register('toolu_named_agent', 'Agent', {
|
||||
_intent: 'Review the PR',
|
||||
prompt: 'Review the PR',
|
||||
name: 'reviewer',
|
||||
})
|
||||
|
||||
const blocks: ContentBlock[] = [
|
||||
makeToolResultBlock('toolu_named_agent', 'Done.\nagentId: named_agent_xyz'),
|
||||
]
|
||||
|
||||
const events = extractToolResults(blocks, null, undefined, toolIndex)
|
||||
|
||||
expect(events).toHaveLength(1)
|
||||
expect(events[0]).toMatchObject({
|
||||
type: 'tool_result',
|
||||
toolUseId: 'toolu_named_agent',
|
||||
})
|
||||
})
|
||||
|
||||
it.each([undefined, true])(
|
||||
'does NOT emit task_backgrounded for a nested Agent when the flag is %s',
|
||||
(runInBackground) => {
|
||||
toolIndex.register('toolu_nested_agent', 'Agent', {
|
||||
prompt: 'Find auth code',
|
||||
...(runInBackground === undefined
|
||||
? {}
|
||||
: { run_in_background: runInBackground }),
|
||||
})
|
||||
|
||||
const blocks: ContentBlock[] = [
|
||||
makeToolResultBlock(
|
||||
'toolu_nested_agent',
|
||||
'Found auth code\nagentId: nested_agent_xyz',
|
||||
),
|
||||
]
|
||||
|
||||
const events = extractToolResults(
|
||||
blocks,
|
||||
'parent_agent',
|
||||
undefined,
|
||||
toolIndex,
|
||||
)
|
||||
|
||||
expect(events).toHaveLength(1)
|
||||
expect(events[0]).toMatchObject({
|
||||
type: 'tool_result',
|
||||
toolUseId: 'toolu_nested_agent',
|
||||
parentToolUseId: 'parent_agent',
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
it('detects background Shell from shell_id in result', () => {
|
||||
toolIndex.register('toolu_bash', 'Bash', { command: 'npm test', description: 'Run tests' })
|
||||
|
||||
|
||||
@@ -263,7 +263,14 @@ export function extractToolResults(
|
||||
|
||||
// Detect background tasks/shells from results
|
||||
if (entry) {
|
||||
const bgEvents = detectBackgroundEvents(toolUseId, entry, resultStr, isError, turnId);
|
||||
const bgEvents = detectBackgroundEvents(
|
||||
toolUseId,
|
||||
entry,
|
||||
resultStr,
|
||||
isError,
|
||||
turnId,
|
||||
sdkParentToolUseId ?? undefined,
|
||||
);
|
||||
events.push(...bgEvents);
|
||||
}
|
||||
}
|
||||
@@ -395,15 +402,41 @@ function detectBackgroundEvents(
|
||||
resultStr: string,
|
||||
isError: boolean,
|
||||
turnId?: string,
|
||||
parentToolUseId?: string,
|
||||
): AgentEvent[] {
|
||||
const events: AgentEvent[] = [];
|
||||
|
||||
// Background Task detection — Task/Agent tool with agentId in result.
|
||||
// Only trigger when the tool was explicitly launched with run_in_background: true.
|
||||
// Without this guard, foreground Agent tools whose result text happens to contain
|
||||
// "agentId:" would be spuriously marked as backgrounded — and since no task_completed
|
||||
// event ever arrives for them, they'd stay stuck in 'backgrounded' status forever.
|
||||
const wasRunInBackground = entry.input?.run_in_background === true;
|
||||
// Qwen Agent calls default to background unless they explicitly opt out or
|
||||
// use a launch shape that stays foreground. Task keeps its provider-specific
|
||||
// explicit opt-in behavior.
|
||||
//
|
||||
// NOTE: This heuristic (top-level `agent` call, no explicit
|
||||
// `run_in_background`, no `working_dir`, no named teammate) mirrors two other
|
||||
// implementations that must stay in sync:
|
||||
// - core dispatch (source of truth): packages/core/src/tools/agent/agent.ts
|
||||
// (`backgroundRequested`/`shouldRunInBackground` in AgentTool.execute)
|
||||
// - web-shell UI: packages/web-shell/client/adapters/toolClassification.ts
|
||||
// (`isBackgroundSubAgentToolCall`)
|
||||
// If the routing rule changes in core, update all three. This copy does not
|
||||
// have the web-shell's `rawOutput.status === 'background'` fallback.
|
||||
const normalizedToolName = entry.name.toLowerCase();
|
||||
const isTopLevelQwenAgent =
|
||||
normalizedToolName === 'agent' && parentToolUseId === undefined;
|
||||
const defaultsToBackground =
|
||||
isTopLevelQwenAgent &&
|
||||
entry.input.run_in_background === undefined &&
|
||||
entry.input.working_dir === undefined &&
|
||||
entry.input.name === undefined &&
|
||||
// Core keeps fork fallbacks in the foreground (`!isForkRequested` in
|
||||
// AgentTool.execute), so an omitted-flag `subagent_type: "fork"` call must
|
||||
// not be classified as background here.
|
||||
(typeof entry.input.subagent_type !== 'string' ||
|
||||
entry.input.subagent_type.toLowerCase() !== 'fork');
|
||||
const wasRunInBackground =
|
||||
(entry.input.run_in_background === true &&
|
||||
(normalizedToolName !== 'agent' || isTopLevelQwenAgent)) ||
|
||||
defaultsToBackground;
|
||||
if (isParentTaskTool(entry.name) && wasRunInBackground && !isError && resultStr) {
|
||||
const agentIdMatch = resultStr.match(/agentId:\s*([a-zA-Z0-9_-]+)/);
|
||||
if (agentIdMatch?.[1]) {
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { ACPToolCall } from './types';
|
||||
import { isBackgroundSubAgentToolCall } from './toolClassification';
|
||||
|
||||
function agentTool(args: Record<string, unknown> = {}): ACPToolCall {
|
||||
return {
|
||||
callId: 'agent-1',
|
||||
toolName: 'agent',
|
||||
args,
|
||||
status: 'completed',
|
||||
};
|
||||
}
|
||||
|
||||
describe('isBackgroundSubAgentToolCall', () => {
|
||||
it('treats an ordinary agent as background when the flag is omitted', () => {
|
||||
expect(isBackgroundSubAgentToolCall(agentTool())).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps an omitted-flag fork request in the foreground', () => {
|
||||
// Mirrors core's `!isForkRequested` guard: a `subagent_type: "fork"`
|
||||
// fallback stays foreground even when run_in_background is omitted.
|
||||
expect(
|
||||
isBackgroundSubAgentToolCall(agentTool({ subagent_type: 'fork' })),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps an explicit foreground agent out of the background group', () => {
|
||||
expect(
|
||||
isBackgroundSubAgentToolCall(agentTool({ run_in_background: false })),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps caller-owned working_dir launches in the foreground by default', () => {
|
||||
expect(
|
||||
isBackgroundSubAgentToolCall(
|
||||
agentTool({ working_dir: '.qwen/worktrees/review' }),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('does not change named teammate classification', () => {
|
||||
expect(isBackgroundSubAgentToolCall(agentTool({ name: 'reviewer' }))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it.each([undefined, true])(
|
||||
'keeps nested Agent calls in the foreground when the flag is %s',
|
||||
(runInBackground) => {
|
||||
expect(
|
||||
isBackgroundSubAgentToolCall({
|
||||
...agentTool(
|
||||
runInBackground === undefined
|
||||
? {}
|
||||
: { run_in_background: runInBackground },
|
||||
),
|
||||
parentToolCallId: 'parent-agent',
|
||||
}),
|
||||
).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it('trusts the runtime background status when present', () => {
|
||||
expect(
|
||||
isBackgroundSubAgentToolCall({
|
||||
...agentTool({ run_in_background: false }),
|
||||
rawOutput: { type: 'task_execution', status: 'background' },
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -19,11 +19,39 @@ export function isSubAgentToolCall(tool: ACPToolCall): boolean {
|
||||
return Boolean(tool.args?.subagent_type);
|
||||
}
|
||||
|
||||
// NOTE: This background-classification heuristic (top-level `agent` call, no
|
||||
// explicit `run_in_background`, no `working_dir`, no named teammate) mirrors two
|
||||
// other implementations that must stay in sync:
|
||||
// - core dispatch (source of truth): packages/core/src/tools/agent/agent.ts
|
||||
// (`backgroundRequested`/`shouldRunInBackground` in AgentTool.execute)
|
||||
// - desktop UI: packages/desktop/packages/shared/src/agent/tool-matching.ts
|
||||
// (`detectBackgroundEvents`)
|
||||
// If the routing rule changes in core, update all three. Divergences already
|
||||
// exist (e.g. `subagentConfig.background` is invisible here; the desktop copy
|
||||
// lacks the `rawOutput.status === 'background'` fallback below).
|
||||
export function isBackgroundSubAgentToolCall(tool: ACPToolCall): boolean {
|
||||
if (!isSubAgentToolCall(tool)) return false;
|
||||
const rawOutput = getRecord(tool.rawOutput);
|
||||
const name = tool.toolName.toLowerCase();
|
||||
const args = tool.args;
|
||||
const isTopLevelQwenAgent =
|
||||
name === 'agent' && tool.parentToolCallId === undefined;
|
||||
const defaultsToBackground =
|
||||
isTopLevelQwenAgent &&
|
||||
args?.run_in_background === undefined &&
|
||||
args?.working_dir === undefined &&
|
||||
args?.name === undefined &&
|
||||
// Core keeps fork fallbacks in the foreground (`!isForkRequested` in
|
||||
// AgentTool.execute), so an omitted-flag `subagent_type: "fork"` call must
|
||||
// not be classified as background here.
|
||||
(typeof args?.subagent_type !== 'string' ||
|
||||
args.subagent_type.toLowerCase() !== 'fork');
|
||||
const explicitlyBackground =
|
||||
args?.run_in_background === true &&
|
||||
(name !== 'agent' || isTopLevelQwenAgent);
|
||||
return (
|
||||
rawOutput?.['status'] === 'background' ||
|
||||
tool.args?.run_in_background === true
|
||||
explicitlyBackground ||
|
||||
defaultsToBackground
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user