Merge pull request #6035 from deepseek-harness/feat/team-interrupt-config

feat(agent-team): allow disabling the interrupt tool
This commit is contained in:
Dudu-0223 authored and GitHub committed 2026-10-12 16:22:13 +08:00
commit 410244be7e
4 files changed
+68 -18

No files matched your search

+2
View File
@@ -1253,6 +1253,8 @@ export interface Config {
```ts config-catalog
/** Tool routing configuration. */
export interface Config {
/** Register `interrupt_agent` and its policy guidance. Defaults to true; false leaves the domain API available. */
readonly interrupt?: boolean
/** Continuable-subagent provider used for fresh teammates. */
readonly freshProvider?: string
/** Continuable-subagent provider used for completed-prefix fork teammates. */
+2
View File
@@ -1255,6 +1255,8 @@ export interface Config {
```ts config-catalog
/** Tool routing configuration. */
export interface Config {
/** Register `interrupt_agent` and its policy guidance. Defaults to true; false leaves the domain API available. */
readonly interrupt?: boolean
/** Continuable-subagent provider used for fresh teammates. */
readonly freshProvider?: string
/** Continuable-subagent provider used for completed-prefix fork teammates. */
@@ -15,6 +15,8 @@ export const inject = ['agents', 'agentTeams', 'tools', 'systemPrompt']
/** Tool routing configuration. */
export interface Config {
/** Register `interrupt_agent` and its policy guidance. Defaults to true; false leaves the domain API available. */
readonly interrupt?: boolean
/** Continuable-subagent provider used for fresh teammates. */
readonly freshProvider?: string
/** Continuable-subagent provider used for completed-prefix fork teammates. */
@@ -23,18 +25,21 @@ export interface Config {
/** Loader schema for the opt-in Team tool plugin. */
export const Config: z<Config> = z.object({
interrupt: z.boolean().default(true),
freshProvider: z.string().default('spawn'),
forkProvider: z.string().default('fork'),
})
/** Model-facing collaboration guidance shared by Lead and teammates. */
const POLICY = `Agent Teams is available in this session, but create teammates only when the user explicitly asks to use Agent Teams or teammates.
function teamPolicy(interrupt: boolean): string {
return `Agent Teams is available in this session, but create teammates only when the user explicitly asks to use Agent Teams or teammates.
The Team Lead and all teammates share the same working directory and filesystem. Edits are immediately visible to every member. Split write work into disjoint scopes, record expected write scopes on shared tasks, and use task dependencies when work must be ordered. Write-scope overlap is advisory, not a lock.
Prefer read/edit/write for file changes. If a file operation returns FS_STALE_VERSION, read the current file, rebase your intended change onto the new content, and retry. Bash, formatters, code generators, and scripts are not fully protected by the filesystem version guard; coordinate them explicitly and have the Lead review the final diff and run tests.
Use the target returned by spawn_teammate or list_agents for send_message and interrupt_agent, or as owner when assigning or filtering shared tasks. send_message steers a running target at its nearest step boundary and starts or resumes an inactive target. inactive means no turn is executing; it does not describe task completion, success, failure, or waiting for other agents. provisioning means member creation is in progress; failed means member creation failed. A peer message starts with its sender name. A successful send does not mean the recipient has processed it. Shared-task workflow is list, get, claim with the current revision, perform the work, then complete. Task readiness never starts an owner. Before wait_agent, use list_agents and make sure another required member is running or provisioning; use send_message first when the required member is inactive. wait_agent observes only changes after that call starts, never wakes a member, and returns noProgress immediately when no other member can produce a change. Re-list after wakeup or timeout. The Lead must wait for required teammates before giving the final answer.`
Use the target returned by spawn_teammate or list_agents for send_message${interrupt ? ' and interrupt_agent' : ''}, or as owner when assigning or filtering shared tasks. send_message steers a running target at its nearest step boundary and starts or resumes an inactive target. inactive means no turn is executing; it does not describe task completion, success, failure, or waiting for other agents. provisioning means member creation is in progress; failed means member creation failed. A peer message starts with its sender name. A successful send does not mean the recipient has processed it. Shared-task workflow is list, get, claim with the current revision, perform the work, then complete. Task readiness never starts an owner. Before wait_agent, use list_agents and make sure another required member is running or provisioning; use send_message first when the required member is inactive. wait_agent observes only changes after that call starts, never wakes a member, and returns noProgress immediately when no other member can produce a change. Re-list after wakeup or timeout. The Lead must wait for required teammates before giving the final answer.`
}
const ACTIVE_WAIT_STATUSES: ReadonlySet<TeamMemberView['status']> = new Set(['running', 'provisioning'])
const NO_ACTIVE_PEER_MESSAGE = 'No other Team member is running or provisioning. wait_agent cannot make progress or wake inactive teammates. Re-list with list_agents and team_task_list, then use send_message to wake each required inactive teammate before waiting again.'
@@ -168,7 +173,7 @@ function install(agent: Agent, ctx: Context, config: Required<Config>): () => vo
register(scoped.systemPrompt.section({
name: 'team:policy',
order: scoped.systemPrompt.getSectionOrder('TEAM_POLICY'),
text: POLICY,
text: teamPolicy(config.interrupt),
}))
register(scoped.tools.register(defineTool({
@@ -274,17 +279,19 @@ To message another teammate, use send_message({ target: "<teammate name>", messa
},
})))
register(scoped.tools.register(defineTool({
name: 'interrupt_agent',
description: 'Interrupt one teammate\'s current turn while preserving its pending inbox. Team Lead only.',
parameters: {
target: { type: 'string', required: true, description: 'Teammate target returned by spawn_teammate or list_agents.' },
},
output: jsonOutput(INTERRUPT_VALUE_SCHEMA),
execute(args, exec) {
return Promise.resolve(ctx.agentTeams.interrupt(callingAgent(exec.agent, 'interrupt_agent'), args.target))
},
})))
if (config.interrupt) {
register(scoped.tools.register(defineTool({
name: 'interrupt_agent',
description: 'Interrupt one teammate\'s current turn while preserving its pending inbox. Team Lead only.',
parameters: {
target: { type: 'string', required: true, description: 'Teammate target returned by spawn_teammate or list_agents.' },
},
output: jsonOutput(INTERRUPT_VALUE_SCHEMA),
execute(args, exec) {
return Promise.resolve(ctx.agentTeams.interrupt(callingAgent(exec.agent, 'interrupt_agent'), args.target))
},
})))
}
register(scoped.tools.register(defineTool({
name: 'team_task_create',
@@ -401,6 +408,7 @@ To message another teammate, use send_message({ target: "<teammate name>", messa
/** Install Team tools in every live or subsequently published Team member scope. */
export function apply(ctx: Context, config: Config = {}): void {
const resolved: Required<Config> = {
interrupt: config.interrupt ?? true,
freshProvider: config.freshProvider ?? 'spawn',
forkProvider: config.forkProvider ?? 'fork',
}
@@ -179,6 +179,31 @@ describe('dsh-tool-team', () => {
expect(interrupt).toHaveBeenCalledWith(lead, 'reviewer')
})
it('omits interrupt tools and guidance for Leads and new teammates when disabled', async () => {
const { ctx, lead, fiber } = await setup(['hang'])
await fiber.dispose()
await ctx.plugin(toolTeam, { interrupt: false })
const spawned = await execute(ctx, lead, 'spawn_teammate', {
name: 'worker', description: 'stay active', prompt: 'wait',
})
expect(spawned.isError, text(spawned)).toBe(false)
const childId = spawnedChildId(ctx, lead, spawned)
const child = await waitRunning(ctx, childId)
for (const agent of [lead, child]) {
const assembled = await assembly(ctx, agent)
expect(assembled.tools.map(schema => schema.name).filter(name => TOOL_NAMES.includes(name)).sort())
.toEqual(TOOL_NAMES.filter(name => name !== 'interrupt_agent'))
expect(renderPrompt(assembled)).not.toContain('interrupt_agent')
const result = await execute(ctx, agent, 'interrupt_agent', { target: 'worker' })
expect(result.isError).toBe(true)
expect(text(result)).toContain('unknown tool "interrupt_agent"')
}
expect(child.status).toBe('running')
expect((await execute(ctx, lead, 'list_agents', {})).isError).toBe(false)
expect(ctx.agentTeams.interrupt(lead, 'worker')).toEqual({ previousStatus: 'running' })
await waitNoAgent(ctx, childId)
})
it('uses returned targets for messages, interruption, and task assignment', async () => {
const { ctx, lead } = await setup(['hang', 'hang'])
const spawned = await execute(ctx, lead, 'spawn_teammate', {
@@ -622,6 +647,16 @@ describe('dsh-tool-team', () => {
expect((await assembly(ctx, child)).tools.map(schema => schema.name).some(name => TOOL_NAMES.includes(name))).toBe(false)
expect(ctx.agents.get(childId)).toBe(child)
const disabled = await ctx.plugin(toolTeam, { interrupt: false })
for (const agent of [lead, child]) {
const assembled = await assembly(ctx, agent)
expect(assembled.tools.map(schema => schema.name).filter(name => TOOL_NAMES.includes(name)).sort())
.toEqual(TOOL_NAMES.filter(name => name !== 'interrupt_agent'))
expect(renderPrompt(assembled)).not.toContain('interrupt_agent')
}
expect(child.status).toBe('running')
await disabled.dispose()
const replacement = await ctx.plugin(toolTeam)
expect((await assembly(ctx, lead)).tools.map(schema => schema.name).filter(name => TOOL_NAMES.includes(name)).sort())
.toEqual(TOOL_NAMES)
@@ -676,8 +711,10 @@ describe('dsh-tool-team', () => {
await vi.waitFor(() => { expect(ctx.agents.get(ordinary.childId)).toBeUndefined() }, { timeout: 5_000 })
})
it('reinstalls Team scope before a cold-resumed teammate request', async () => {
const { ctx, lead, adapter } = await setup([textResponse('first'), textResponse('lead received settlement'), 'hang'])
it.each([true, false])('reinstalls Team scope with interrupt=%s before a cold-resumed request', async (interrupt) => {
const { ctx, lead, adapter, fiber } = await setup([textResponse('first'), textResponse('lead received settlement'), 'hang'])
await fiber.dispose()
await ctx.plugin(toolTeam, { interrupt })
const spawned = await execute(ctx, lead, 'spawn_teammate', {
name: 'cold-worker', description: 'cold worker', prompt: 'finish once',
})
@@ -700,7 +737,8 @@ describe('dsh-tool-team', () => {
expect(receipt.messageId).toEqual(expect.any(String))
const resumed = await waitRunning(ctx, childId)
expect((await assembly(ctx, resumed)).tools.map(schema => schema.name)
.filter(name => TOOL_NAMES.includes(name)).sort()).toEqual(TOOL_NAMES)
.filter(name => TOOL_NAMES.includes(name)).sort()).toEqual(TOOL_NAMES.filter(name => interrupt || name !== 'interrupt_agent'))
expect(renderPrompt(await assembly(ctx, resumed)).includes('interrupt_agent')).toBe(interrupt)
expect(renderContextSnapshot(await assembly(ctx, resumed))).not.toContain('You are teammate')
const childRequests = () => adapter.requests.filter(request => request.messages.some(message => message.role === 'user'
&& message.content.some(block => block.type === 'text' && block.text.includes('You are teammate "cold-worker".'))))
@@ -709,7 +747,7 @@ describe('dsh-tool-team', () => {
expect(childRequests()[1]!.messages.slice(0, firstMessages.length)).toEqual(firstMessages)
expect(resumed.session.snapshotEvents().filter(event => event.type === 'user/message'
&& event.data.source.kind === 'user')).toHaveLength(1)
await execute(ctx, lead, 'interrupt_agent', { target: 'cold-worker' })
ctx.agentTeams.interrupt(lead, 'cold-worker')
await vi.waitFor(() => { expect(ctx.agents.get(childId)).toBeUndefined() }, { timeout: 5_000 })
})