@nanmicoder/dsh-agent-teams 0.1.17 → 0.1.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,6 @@
1
+ import { createUserMessage } from '@deepseek-ai/dsh-llm';
1
2
  import { SubagentError } from '@deepseek-ai/dsh-subagent';
3
+ import { CAPTAIN_TOOL_NAMES } from "./tool-names.js";
2
4
  /**
3
5
  * Exact protocol exported by dsh-subagent/internal in Alpha.5 and rc.1.
4
6
  * That subpath does not exist in Alpha.2, so importing it statically prevents
@@ -105,6 +107,27 @@ export async function queueMemberPrompt(runtime, parent, childId, content, signa
105
107
  return unsupported('missing host FIFO delivery');
106
108
  return queue.call(runtime, parent, childId, content, source, signal);
107
109
  }
110
+ /** Coordination joins the nearest step, including waking an idle/cold child. */
111
+ export async function steerMemberPrompt(runtime, parent, childId, content, signal, live) {
112
+ const host = boundary(runtime);
113
+ const source = { kind: 'plugin', plugin: 'dsh-agent-teams' };
114
+ const deliver = host[hostPromptDeliver];
115
+ if (typeof deliver === 'function')
116
+ return deliver.call(runtime, parent, childId, content, source, signal, 'steer');
117
+ if (typeof host.sendMessage === 'function')
118
+ return host.sendMessage.call(runtime, parent, childId, content, { signal });
119
+ if (typeof host.followup === 'function') {
120
+ if (live === undefined)
121
+ return queueMemberPrompt(runtime, parent, childId, content, signal);
122
+ if (live.id !== childId || live.session.header.parentSession !== parent.id)
123
+ throw new Error('invalid member steering authority');
124
+ signal.throwIfAborted();
125
+ const message = createUserMessage({ content, source });
126
+ live.steer(message);
127
+ return message.id;
128
+ }
129
+ return unsupported('missing step-boundary message delivery');
130
+ }
108
131
  /** Guard all resumable delivery paths, preserving the native service receiver. */
109
132
  export function guardSubagentDelivery(ctx, isRetired) {
110
133
  const runtime = ctx.subagents;
@@ -177,3 +200,86 @@ export function guardSubagentDelivery(ctx, isRetired) {
177
200
  };
178
201
  }, 'agent-teams: retired member guard');
179
202
  }
203
+ /**
204
+ * Names the host's `tools.restrict()` would admit for this agent's layer
205
+ * chain, or undefined when the host does not expose its registry view.
206
+ * restrict() rejects unknown names with a hard error, so the spawn path must
207
+ * check entries against this view before forwarding them. Source:
208
+ * dsh-tools view()/restrict() in 0.1.5-rc.1; older Harness generations lack
209
+ * the surface and keep the verbatim list.
210
+ */
211
+ export function restrictableToolNames(agent) {
212
+ const tools = agent?.ctx?.tools;
213
+ if (typeof tools?.view !== 'function')
214
+ return undefined;
215
+ try {
216
+ const names = tools.view.call(tools, agent.ctx)?.restrictableNames;
217
+ return names instanceof Set ? names : undefined;
218
+ }
219
+ catch {
220
+ return undefined;
221
+ }
222
+ }
223
+ /**
224
+ * Member `toolFilter` for one spawn. Captain-only names are registered by
225
+ * this plugin itself and always resolvable; the depth-related entries name
226
+ * HOST tools and are only known under some compositions, so they must be
227
+ * resolved against the running host's registry: the delegation tool name is
228
+ * host configuration (dsh-tool-subagent `toolName`, default `subagent`,
229
+ * renamed per composition, e.g. `subagent_fork` in web-style profiles), and
230
+ * forwarding a name the host dropped aborts every member spawn (#163, #164).
231
+ * Depth enforcement itself does not depend on these names —
232
+ * installMemberDelegationGuard bounds descendant creation by parent chain.
233
+ */
234
+ export function memberToolFilter(maxDepth, knownTools) {
235
+ const depthDeny = maxDepth === 0 ? ['subagent', 'send_message'] : [];
236
+ return {
237
+ deny: [
238
+ ...CAPTAIN_TOOL_NAMES,
239
+ ...(knownTools === undefined ? depthDeny : depthDeny.filter(name => knownTools.has(name))),
240
+ ],
241
+ };
242
+ }
243
+ /**
244
+ * Names a `tools.restrict()` rejection reported as unknown, or an empty list
245
+ * for every other failure. The host names the offenders verbatim
246
+ * (`tools.restrict() names unknown global tool "x"; known global tools: …`);
247
+ * only that report may relax a filter (#164, #166).
248
+ */
249
+ function unknownToolNames(error) {
250
+ const message = error instanceof Error ? error.message : String(error);
251
+ if (!message.includes('unknown global tool'))
252
+ return [];
253
+ return [...message.matchAll(/"([^"]+)"/g)].map(match => match[1]);
254
+ }
255
+ /**
256
+ * Start one member, dropping any filter name the host reports as unknown and
257
+ * retrying.
258
+ *
259
+ * Resolving the filter against the registry view ({@link memberToolFilter})
260
+ * only covers hosts that expose one. A rejected filter still leaves the member
261
+ * without a session for the lifetime of the team, so the start must survive the
262
+ * cases that resolution cannot see — no registry view, a name that is known but
263
+ * not restrictable, a composition that mounted differently than expected. The
264
+ * host names the offenders in its rejection, so they are removed and the start
265
+ * retried; the retry only runs while the deny list strictly shrinks, and any
266
+ * failure that is not an unknown-name report propagates untouched.
267
+ * @param start - performs one start attempt for the given `toolFilter`.
268
+ * @param filter - the filter to attempt first.
269
+ * @returns the started member.
270
+ */
271
+ export async function startMemberWithLenientFilter(start, filter) {
272
+ let deny = [...filter.deny];
273
+ for (;;) {
274
+ try {
275
+ return await start({ deny });
276
+ }
277
+ catch (error) {
278
+ const unknown = unknownToolNames(error);
279
+ const remaining = deny.filter(name => !unknown.includes(name));
280
+ if (unknown.length === 0 || remaining.length === deny.length)
281
+ throw error;
282
+ deny = remaining;
283
+ }
284
+ }
285
+ }
package/lib/index.js CHANGED
@@ -80,7 +80,7 @@ export const Config = z.object({
80
80
  dependencies: z.array(z.string()),
81
81
  })),
82
82
  })).default({}),
83
- memberMaxDepth: z.natural().default(1),
83
+ memberMaxDepth: z.natural().default(0),
84
84
  maxMembers: z.natural().min(1).default(8),
85
85
  promptSectionOrder: z.natural().default(117),
86
86
  slashCommand: z.boolean().default(true),
@@ -90,11 +90,11 @@ export function usageSectionText(toolNames, profilesText = '') {
90
90
  return `AgentTeams captain protocol:
91
91
  1. Inspect current team state when needed, using agent_teams_status. Continue existing work without duplicating its roster/tasks. Create only when no current team exists, with the user's goal as description and approval="required"; automatic approval requires an explicit request to run immediately. Staged plans never spawn or schedule work.
92
92
  2. Add each needed role once; members inherit your model route unless another is requested/needed. A requested profile goes to create({profile}); it supplies its roster. Seed profiles also supply tasks; captain-planning profiles require your DAG. Do not duplicate either.
93
- 3. Build the complete smallest useful DAG while staged. Every task needs a subject; dependencies represent prerequisites. Give every required contributor a task or explicit message. Present the plan and end your turn for review; never approve in that planning turn. Approve only after a later explicit user approval or the Web action.
93
+ 3. Build the complete smallest useful DAG while staged. For an ordinary research/audit plan, pass the roster and dependency graph together in create({plan:{members,tasks}}) to avoid repeated setup rounds. Every task needs a subject; pass kind and assignee explicitly when the plan specifies them. Titles, descriptions and member roles do not set these fields. Dependencies represent prerequisites. Give every required contributor a task or explicit message. Present the plan and end your turn for review; never approve in that planning turn. Approve only after a later explicit user approval or the Web action.
94
94
  4. Respect Web approve/return/discard control messages. On return, ask what to change before editing; after the answer, use one atomic agent_teams_edit_plan batch (edit downstream references before removals), summarize and await review again. Never inspect or edit .agent-teams state files or plugin source code to revise plans. Discard does not authorize a replacement.
95
95
  5. The scheduler dispatches ready tasks after approval. Delegate; do not duplicate slow work or send messages merely to start a stage. Handle reports/user work, then yield when waiting is all that remains: reports wake you automatically. Use status after a delivery or user request, never busy-poll or wait for unassigned members.
96
96
  6. Tasks carry attempt_id capabilities. Use the current attempt_id; stale means ownership changed. Pause members only on explicit request; later guidance via send_message continues that same attempt. Retry, transfer or take over through reassign_task first; it revokes the old attempt and waits for quiescence. Prefer a member. Captain implementation/review takeover requires a user request. Every takeover is one ready task at a time, finished in this turn; never yield with captain-owned work open.
97
- 7. Quality kinds (requirements, implementation, verification, review, repair, integration) require objective + acceptance; implementation/repair also require inScope + verify. Derive paths/commands from the workspace/profile, never assume src/ or pnpm test. Review/requirements complete only with verdict=pass; needs_revision/reject fail with findings. Never approve your own implementation or ask for a deliberate failure.
97
+ 7. In a running team, correct never-started pending tasks with edit_plan update_task; preserve dependency and ownership contracts instead of cancelling and recreating the graph. A captain can cancel a never-started pending task directly. Active attempts still require reassign_task. Quality kinds (requirements, implementation, verification, review, repair, integration) require objective + acceptance; implementation/repair also require inScope + verify. Derive paths/commands from the workspace/profile, never assume src/ or pnpm test. Review/requirements complete only with verdict=pass; needs_revision/reject fail with findings. Never approve your own implementation or ask for a deliberate failure. When a quality contract itself is wrong (a verify command that cannot pass, an inScope that forbids the file the objective requires), fix it with agent_teams_amend_task — captain-only, non-terminal tasks only, frozen after a passing review, and every amendment is recorded in the task's revisions ledger — instead of letting the worker dead-lock or game the gate.
98
98
  8. When full quality mode is requested: requirements → implementation → verification → review → integration. Plan the entire DAG while staged, including implementation before requirements finishes and integration depending on review round 1. Failed review automatically adds repair + next review and rewires pending downstream gates. Do not recreate this loop, omit integration or depend on a failed task. Review acceptance judges the latest implementation. Do not put smoke-test scripts into task instructions.
99
99
  9. Halted means the user stopped work (including the captain turn). Resume only on a later explicit user request with a reason, via agent_teams_resume or create_task({resume:true,resumeReason}); creating tasks alone never resumes. Escalated means the review loop hit its limit, not a halt. Deployment requires explicit user confirmation.
100
100
  10. Wait for all required tasks to be terminal and members idle/ready, present results, then delete/archive unless the user wants to continue. Never discard unfinished work without authorization.
@@ -107,7 +107,7 @@ export function apply(ctx, config) {
107
107
  memberModel: config.memberModel,
108
108
  executionPrompt: config.executionPrompt,
109
109
  fallback: config.fallback,
110
- memberMaxDepth: config.memberMaxDepth ?? 1,
110
+ memberMaxDepth: config.memberMaxDepth ?? 0,
111
111
  maxMembers: config.maxMembers ?? 8,
112
112
  profiles: config.profiles ?? {},
113
113
  };
package/lib/mailbox.js ADDED
@@ -0,0 +1,154 @@
1
+ import { join } from 'node:path';
2
+ import { acknowledgeMailbox, CAPTAIN_KEY, discardMailboxMessages, findTeamByCaptain, readMailbox, readTeam, sanitizeKey, withTeamLock } from "./state.js";
3
+ import { sessionOwnEvents } from "./harness-compat.js";
4
+ const PREFIX = 'AgentTeams inbox receipt: ';
5
+ export function isCurrentMail(team, message) {
6
+ if (message.discardedAt !== undefined)
7
+ return false;
8
+ if (message.attemptId === undefined)
9
+ return true;
10
+ return team.tasks.some(task => task.id === message.taskId && task.attemptId === message.attemptId
11
+ && task.assignee === message.to && (task.status === 'claimed' || task.status === 'in_progress'));
12
+ }
13
+ function mailboxBody(recipient, messages) {
14
+ return messages.map(message => recipient === CAPTAIN_KEY
15
+ ? `AgentTeams message from member ${message.from}:\n\n${message.content}`
16
+ : `AgentTeams message from ${message.from}${message.attemptId === undefined ? '' : ` for task ${message.taskId}, attempt_id ${message.attemptId}`}:\n\n${message.content}`).join('\n\n');
17
+ }
18
+ export function mailboxPrompt(teamId, recipient, messages) {
19
+ return PREFIX + JSON.stringify({ teamId, recipient, ids: messages.map(message => message.id) }) + '\n\n' + mailboxBody(recipient, messages);
20
+ }
21
+ function parseReceipt(text) {
22
+ if (!text.startsWith(PREFIX))
23
+ return undefined;
24
+ try {
25
+ const value = JSON.parse(text.slice(PREFIX.length).split('\n')[0]);
26
+ if (typeof value !== 'object' || value === null)
27
+ return undefined;
28
+ const raw = value;
29
+ if (typeof raw['teamId'] !== 'string' || typeof raw['recipient'] !== 'string'
30
+ || !Array.isArray(raw['ids']) || !raw['ids'].every(id => typeof id === 'string'))
31
+ return undefined;
32
+ if (raw['teamId'] !== sanitizeKey(raw['teamId']))
33
+ return undefined;
34
+ return { teamId: raw['teamId'], recipient: raw['recipient'], ids: raw['ids'] };
35
+ }
36
+ catch {
37
+ return undefined;
38
+ }
39
+ }
40
+ /** Only messages actually admitted to this recipient's step become read. */
41
+ export function installMailboxAdmission(ctx, stateDir) {
42
+ ctx.on('agent/pre-step', async (payload, next) => {
43
+ const decision = await next();
44
+ if (decision.kind !== 'enter')
45
+ return decision;
46
+ const root = join(payload.agent.session.header.cwd ?? process.cwd(), stateDir);
47
+ // The native runtime also wakes a parent when a background activation
48
+ // settles. A successfully delivered Team report already carries that
49
+ // result. Keep abnormal endings and missing reports as fallback signals.
50
+ const successfulSettlements = new Set(decision.messages.flatMap(input => {
51
+ const source = input.source;
52
+ return source?.kind === 'subagent-settled'
53
+ && source.summary === `Background subagent ${source.senderSessionId} finished and will do no further work unless you send it more.`
54
+ ? [source.senderSessionId] : [];
55
+ }));
56
+ const redundantSettlements = new Set();
57
+ if (successfulSettlements.size > 0) {
58
+ const owned = await findTeamByCaptain(root, payload.agent.id);
59
+ if (owned !== undefined)
60
+ await withTeamLock(`team:${root}:${owned.id}`, async () => {
61
+ const team = await readTeam(root, owned.id);
62
+ if (team?.captainSessionId !== payload.agent.id || team.halted)
63
+ return;
64
+ const reports = await readMailbox(root, team.id, CAPTAIN_KEY);
65
+ for (const member of team.members) {
66
+ if (!successfulSettlements.has(member.id) || member.status === 'removed' || member.stopping)
67
+ continue;
68
+ const tasks = team.tasks.filter(task => task.assignee === member.name);
69
+ if (tasks.some(task => task.status === 'claimed' || task.status === 'in_progress'))
70
+ continue;
71
+ const latest = tasks.filter(task => (task.attempt ?? 0) > 0 || task.status === 'completed').sort((a, b) => b.updatedAt - a.updatedAt)[0];
72
+ if (latest?.status === 'completed' && reports.some(report => report.from === member.name && report.ts >= latest.updatedAt
73
+ && report.discardedAt === undefined && (report.deliveredAt !== undefined || report.readAt !== undefined))) {
74
+ redundantSettlements.add(member.id);
75
+ }
76
+ }
77
+ });
78
+ }
79
+ const batches = new Map();
80
+ const inputs = decision.messages.map(input => {
81
+ // Alpha.5/0.1.2-rc.1 public steering prepends a native sender block.
82
+ // The host-authored receipt remains a separate text block after it.
83
+ const receipt = input.content.flatMap(block => block.type === 'text' ? [parseReceipt(block.text)] : []).find(value => value !== undefined);
84
+ if (receipt === undefined)
85
+ return { input };
86
+ const key = JSON.stringify([receipt.teamId, receipt.recipient]);
87
+ let batch = batches.get(key);
88
+ if (batch === undefined) {
89
+ batch = { receipt, ids: new Set(), current: new Map() };
90
+ batches.set(key, batch);
91
+ }
92
+ for (const id of receipt.ids)
93
+ batch.ids.add(id);
94
+ return { input, receipt, batch };
95
+ });
96
+ // One read/acknowledgement per recipient per step, even for a large burst.
97
+ for (const batch of batches.values()) {
98
+ const { receipt } = batch;
99
+ await withTeamLock(`team:${root}:${receipt.teamId}`, async () => {
100
+ const team = await readTeam(root, receipt.teamId);
101
+ if (team === undefined)
102
+ return;
103
+ const recipientId = receipt.recipient === CAPTAIN_KEY ? team.captainSessionId
104
+ : team.members.find(member => member.name === receipt.recipient && member.status !== 'removed' && member.stopping !== true)?.id;
105
+ if (recipientId !== payload.agent.id)
106
+ return;
107
+ const selected = (await readMailbox(root, team.id, receipt.recipient)).filter(message => batch.ids.has(message.id));
108
+ const current = selected.filter(message => message.readAt === undefined && isCurrentMail(team, message));
109
+ await discardMailboxMessages(root, team.id, receipt.recipient, selected.filter(message => !isCurrentMail(team, message)).map(message => message.id));
110
+ await acknowledgeMailbox(root, team.id, receipt.recipient, current.map(message => message.id));
111
+ batch.current = new Map(current.map(message => [message.id, message]));
112
+ });
113
+ }
114
+ const admitted = [];
115
+ for (const { input, receipt, batch } of inputs) {
116
+ if (input.source?.kind === 'subagent-settled' && redundantSettlements.has(input.source.senderSessionId)
117
+ && input.source.summary === `Background subagent ${input.source.senderSessionId} finished and will do no further work unless you send it more.`)
118
+ continue;
119
+ if (receipt === undefined || batch === undefined) {
120
+ admitted.push(input);
121
+ continue;
122
+ }
123
+ const messages = [];
124
+ for (const id of receipt.ids) {
125
+ const message = batch.current.get(id);
126
+ if (message === undefined)
127
+ continue;
128
+ batch.current.delete(id);
129
+ messages.push(message);
130
+ }
131
+ if (messages.length > 0)
132
+ admitted.push({ ...input, content: [{ type: 'text', text: mailboxBody(receipt.recipient, messages) }] });
133
+ }
134
+ // Reject only a new turn whose sole trigger was obsolete mail. During an
135
+ // existing turn, a status tool may have consumed this same mail already;
136
+ // dropping its duplicate must not block the pending tool-result response.
137
+ if (decision.messages.length > 0 && admitted.length === 0) {
138
+ const events = sessionOwnEvents(payload.agent.session);
139
+ let continuing = false;
140
+ for (let i = events.length - 1; i >= 0; i--) {
141
+ const event = events[i];
142
+ if (event.type === 'step/start' && event.data.turn === payload.turn) {
143
+ continuing = true;
144
+ break;
145
+ }
146
+ if (event.type === 'turn/start')
147
+ break;
148
+ }
149
+ if (!continuing)
150
+ return { kind: 'reject' };
151
+ }
152
+ return { ...decision, messages: admitted };
153
+ });
154
+ }
package/lib/members.js CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Member subagent lifecycle: spawn a continuable child per member, deliver
3
- * messages into its FIFO inbox, and observe its activity.
3
+ * task turns and step-boundary messages, and observe its activity.
4
4
  *
5
5
  * Members are durable continuable subagents of the captain, so a member keeps
6
6
  * its conversation across turns and across harness restarts: the captain
@@ -16,15 +16,13 @@ import { installModelSelection } from '@deepseek-ai/dsh-agent';
16
16
  import { foldSubagentDescriptor } from '@deepseek-ai/dsh-subagent';
17
17
  import { createUserMessage, LlmError, ReasoningEffortId } from '@deepseek-ai/dsh-llm';
18
18
  import { join } from 'node:path';
19
- import { guardSubagentDelivery, installContinuableMemberSetup, queueMemberPrompt, sessionOwnEvents } from "./harness-compat.js";
20
- import { acknowledgeMailbox, appendMailbox, CAPTAIN_KEY, createMessage, readRetiredMemberIds, readTeamSync, readTeam, releaseMailboxDelivery, withTeamLock, writeTeam } from "./state.js";
19
+ import { guardSubagentDelivery, installContinuableMemberSetup, memberToolFilter, queueMemberPrompt, restrictableToolNames, startMemberWithLenientFilter, steerMemberPrompt, sessionOwnEvents } from "./harness-compat.js";
20
+ import { markMailboxDelivered, appendMailbox, CAPTAIN_KEY, createMessage, readRetiredMemberIds, readTeamSync, readTeam, releaseMailboxDelivery, withTeamLock, writeTeam } from "./state.js";
21
+ import { mailboxPrompt } from "./mailbox.js";
21
22
  import { appendTeamEvent, captainSessionOf } from "./events.js";
22
23
  import { TERMINAL_TASK_STATUSES } from "./types.js";
23
- import { CAPTAIN_TOOL_NAMES } from "./tool-names.js";
24
24
  /** Persona snapshot of a profile protocol; the full text lives on team.json. */
25
25
  export const PERSONA_PROTOCOL_MAX_CHARS = 400;
26
- /** Captain-only AgentTeams tools hidden from newly spawned members. */
27
- const MEMBER_DENIED_TOOLS = CAPTAIN_TOOL_NAMES;
28
26
  /**
29
27
  * Restore the SessionId brand on a value that round-tripped through the
30
28
  * durable team file. The brand is erased by JSON serialization; the value
@@ -70,10 +68,10 @@ export function selectFallbackRoute(current, fallback, failureCode, alreadySwitc
70
68
  return { retry: true, switched: true, selection: fallback };
71
69
  }
72
70
  /** Deliver a durable member report to the live captain at its next model step. */
73
- export function steerCaptainReport(captain, from, content) {
71
+ export function steerCaptainReport(captain, from, content, receipt) {
74
72
  try {
75
73
  captain.steer(createUserMessage({
76
- content: [{ type: 'text', text: `AgentTeams message from member ${from}:\n\n${content}` }],
74
+ content: [{ type: 'text', text: receipt ?? `AgentTeams message from member ${from}:\n\n${content}` }],
77
75
  source: { kind: 'plugin', plugin: 'dsh-agent-teams' },
78
76
  }));
79
77
  return true;
@@ -138,9 +136,9 @@ export async function failMemberOpenAttempt(ctx, stateRoot, teamId, memberName,
138
136
  // Use the same lease/acknowledgment contract as send_message, outside the
139
137
  // team lock: steering can synchronously start another agent turn.
140
138
  const captain = ctx.agents.get(brandedSessionId(prepared.captainSessionId));
141
- const delivered = captain !== undefined && steerCaptainReport(captain, memberName, prepared.message.content);
139
+ const delivered = captain !== undefined && steerCaptainReport(captain, memberName, prepared.message.content, mailboxPrompt(teamId, CAPTAIN_KEY, [prepared.message]));
142
140
  await withTeamLock(lockKey, () => delivered
143
- ? acknowledgeMailbox(stateRoot, teamId, CAPTAIN_KEY, [prepared.message.id])
141
+ ? markMailboxDelivered(stateRoot, teamId, CAPTAIN_KEY, [prepared.message.id])
144
142
  : releaseMailboxDelivery(stateRoot, teamId, CAPTAIN_KEY, [prepared.message.id]));
145
143
  return true;
146
144
  }
@@ -280,11 +278,26 @@ export function installMemberSelectionRuntime(ctx, stateDir, onFailureSettled) {
280
278
  const workspace = child.session.header.cwd ?? process.cwd();
281
279
  const stateRoot = join(workspace, stateDir);
282
280
  const key = pendingSelectionKey(parentSessionId, descriptor.label);
281
+ // Internal settlement wakeups bypass the public delivery guard. Recheck
282
+ // exact durable membership on every proposed step, including cold resume.
283
+ const disposeAdmission = childCtx.on('agent/pre-step', async (payload, next) => {
284
+ if (payload.agent.id !== child.id)
285
+ return next();
286
+ const admitted = await withTeamLock(`team:${stateRoot}:${teamId}`, async () => {
287
+ const team = await readTeam(stateRoot, teamId);
288
+ return team?.captainSessionId === parentSessionId && team.phase !== 'staged' && team.halted !== true
289
+ && team.members.some(member => member.id === child.id && member.name === memberName && member.status !== 'removed' && member.stopping !== true)
290
+ && !team.tasks.some(task => task.reassigning === true && task.assignee === memberName);
291
+ });
292
+ if (!admitted)
293
+ return { kind: 'reject' };
294
+ return next();
295
+ });
283
296
  let selection = pending.get(key);
284
297
  if (selection === undefined) {
285
298
  const team = readTeamSync(stateRoot, teamId);
286
299
  if (team?.captainSessionId !== parentSessionId)
287
- return () => undefined;
300
+ return disposeAdmission;
288
301
  const durableMember = team.members.find(member => member.name === memberName);
289
302
  selection = selectionFromMember(durableMember);
290
303
  if (selection !== undefined && (descriptor.agentProvider !== durableMember?.provider || descriptor.agentModel !== durableMember?.model)) {
@@ -341,7 +354,7 @@ export function installMemberSelectionRuntime(ctx, stateDir, onFailureSettled) {
341
354
  });
342
355
  // Legacy teams still need failure reporting even without a saved route.
343
356
  if (selection === undefined)
344
- return disposeFailure;
357
+ return () => { disposeFailure(); disposeAdmission(); };
345
358
  const selectionRef = { current: modelSelection(selection), assembled: undefined };
346
359
  const disposeSelection = installModelSelection(childCtx, selectionRef);
347
360
  const fallback = selection.fallback;
@@ -371,6 +384,7 @@ export function installMemberSelectionRuntime(ctx, stateDir, onFailureSettled) {
371
384
  disposeFallback();
372
385
  disposeSelection();
373
386
  disposeFailure();
387
+ disposeAdmission();
374
388
  };
375
389
  });
376
390
  return {
@@ -432,7 +446,7 @@ Team context:
432
446
  ${injectedPrompt === undefined || injectedPrompt === '' ? '' : `- Execution guidance:
433
447
  ${injectedPrompt}
434
448
  `}- The team state lives under ${stateDir}/${team.id}/ (team.json and inbox/*.jsonl). You may inspect these files read-only for diagnostics, but never edit them directly; use the agent_teams_* tools so JSON escaping and concurrent updates stay safe.
435
- - The captain and your teammates reach you through messages. Each message you receive is a new turn: act on it and end your turn with a concise reply.
449
+ - The captain and your teammates reach you through messages. Coordination arrives at your nearest model step. Apply it to your current attempt; a task assignment starts a separate turn.
436
450
  When you receive a task, treat the assignment prompt's dependency results as source material. Do not ignore them.
437
451
 
438
452
  Working rules:
@@ -444,8 +458,8 @@ Working rules:
444
458
  - include a concise output in either case;
445
459
  - a stale-attempt rejection means the captain reassigned or took over the task; stop touching that task and wait for new work.
446
460
  claimed cannot jump to completed. Mark in_progress first, then completed or failed.
447
- Include attempt_id on every update. Then send_message to captain and become idle.
448
- 4. Send a short report to the captain with agent_teams_send_message (to=captain) when you complete a task or hit a blocker.
461
+ Include attempt_id on every update. Then report once as described below and become idle.
462
+ 4. Send one short report with agent_teams_send_message (to=captain) when you complete a task or hit a blocker. The captain is also your parent: this single message satisfies both reporting duties. Do not repeat it through the native send_message tool or send acknowledgments that add no new information.
449
463
  5. To ask a teammate something, use agent_teams_send_message with to=<teammate name>; the message lands in their mailbox and wakes them directly — teammates talk to each other without the captain in the loop. The same applies to the captain (to=captain).
450
464
  6. After your turn becomes idle, the shared task scheduler may assign your next ready task automatically. Never claim a second task while you still own unfinished work.
451
465
  7. If you already own an open attempt (claimed or in_progress) and receive mail, treat it as guidance for that same attempt_id unless the mail explicitly tells you to stop or fail. Do not claim a new task in that turn.
@@ -478,7 +492,7 @@ Do not start work until the scheduler or captain assigns a task in this turn.`;
478
492
  * @param stateDir - configured state directory (for the persona).
479
493
  * @param signal - caller cancellation, forwarded to the start.
480
494
  */
481
- export async function spawnMember(ctx, config, selections, llmSelection, captain, team, member, stateDir, signal) {
495
+ export async function spawnMember(ctx, config, selections, llmSelection, captain, team, member, stateDir, signal, initialPrompt) {
482
496
  // Fail loud at the first use: provider registration is a sibling plugin's
483
497
  // effect and may settle after this plugin mounts. Capability checks here
484
498
  // mirror what startContinuable would reject, with an actionable error.
@@ -497,14 +511,19 @@ export async function spawnMember(ctx, config, selections, llmSelection, captain
497
511
  throw new Error(`agent-teams: provider "${config.provider}" cannot restrict captain-only tools for members`);
498
512
  }
499
513
  const label = `${MEMBER_LABEL_PREFIX}${team.id}:${member.name}`;
500
- const start = await selections.withPending(captain.id, label, llmSelection, () => (ctx.subagents.startContinuable({
514
+ const start = await selections.withPending(captain.id, label, llmSelection, () => (startMemberWithLenientFilter(toolFilter => ctx.subagents.startContinuable({
501
515
  provider: config.provider,
502
516
  label,
503
517
  request: {
504
- prompt: [{ type: 'text', text: memberWelcome(team, member.name) }],
518
+ prompt: [{ type: 'text', text: initialPrompt ?? memberWelcome(team, member.name) }],
505
519
  parent: captain,
506
520
  persona: memberPersona(team, member, stateDir, config.executionPrompt),
507
- toolFilter: { deny: [...MEMBER_DENIED_TOOLS] },
521
+ // Depth deny entries name host tools; resolve them against the
522
+ // running host's registry so a renamed delegation tool cannot abort
523
+ // the spawn (see memberToolFilter). Depth is still enforced by the
524
+ // parent-chain guard installed at plugin mount. A filter the host
525
+ // still rejects is retried without the names it reported.
526
+ toolFilter,
508
527
  agentOptions: {
509
528
  provider: llmSelection.provider,
510
529
  model: llmSelection.model,
@@ -512,10 +531,12 @@ export async function spawnMember(ctx, config, selections, llmSelection, captain
512
531
  ? {}
513
532
  : { reasoningEffort: ReasoningEffortId(llmSelection.reasoningEffort) },
514
533
  },
515
- ...config.maxDepth !== undefined ? { maxDepth: config.maxDepth } : {},
534
+ // Harness request.maxDepth caps the absolute depth of THIS creation;
535
+ // it is not inherited by later delegations. The guard below enforces
536
+ // the configured member-relative descendant budget instead.
516
537
  },
517
538
  signal,
518
- })));
539
+ }), memberToolFilter(config.maxDepth, restrictableToolNames(captain)))));
519
540
  member.id = start.childId;
520
541
  }
521
542
  /**
@@ -535,9 +556,13 @@ export async function spawnMember(ctx, config, selections, llmSelection, captain
535
556
  * @param signal - caller cancellation, forwarded to the delivery.
536
557
  * @returns whether the member inbox accepted the message.
537
558
  */
538
- export async function deliverToMember(ctx, captain, childId, text, signal) {
559
+ export async function deliverToMember(ctx, captain, childId, text, signal, mode = 'queue') {
539
560
  try {
540
- await queueMemberPrompt(ctx.subagents, captain, brandedSessionId(childId), [{ type: 'text', text }], signal);
561
+ const content = [{ type: 'text', text }];
562
+ if (mode === 'steer')
563
+ await steerMemberPrompt(ctx.subagents, captain, brandedSessionId(childId), content, signal, ctx.agents.get(brandedSessionId(childId)));
564
+ else
565
+ await queueMemberPrompt(ctx.subagents, captain, brandedSessionId(childId), content, signal);
541
566
  return true;
542
567
  }
543
568
  catch (error) {
@@ -578,6 +603,55 @@ export function installRetiredMemberGuard(ctx, stateDir) {
578
603
  return retired.has(childId);
579
604
  });
580
605
  }
606
+ /** Bound all descendant creation, including renamed tools and code-runtime calls. */
607
+ export function installMemberDelegationGuard(ctx, stateDir, maxDepth) {
608
+ const runtime = ctx.subagents;
609
+ let active = true;
610
+ const check = (parent) => {
611
+ if (!active)
612
+ return;
613
+ let ancestor = parent;
614
+ let depth = 1;
615
+ while (ancestor !== undefined) {
616
+ const descriptor = foldSubagentDescriptor(sessionOwnEvents(ancestor.session));
617
+ if (descriptor?.label?.startsWith(MEMBER_LABEL_PREFIX)) {
618
+ const identity = descriptor.label.slice(MEMBER_LABEL_PREFIX.length);
619
+ const separator = identity.indexOf(':');
620
+ const team = readTeamSync(join(ancestor.session.header.cwd ?? process.cwd(), stateDir), identity.slice(0, separator));
621
+ const member = team?.members.find(item => item.id === ancestor.id && item.name === identity.slice(separator + 1));
622
+ if (member === undefined || member.status === 'removed' || member.stopping === true || team?.halted === true)
623
+ throw new Error('AgentTeams member is no longer admitting delegated work');
624
+ if (depth > maxDepth)
625
+ throw new Error(`AgentTeams member delegation limit (${maxDepth}) reached; report to the captain instead of spawning another agent`);
626
+ return;
627
+ }
628
+ const parentId = ancestor.session.header.parentSession;
629
+ ancestor = parentId === undefined ? undefined : ctx.agents.get(parentId);
630
+ depth++;
631
+ }
632
+ };
633
+ ctx.effect(() => {
634
+ const start = runtime.start;
635
+ const continuable = runtime.startContinuable;
636
+ const startDescriptor = Object.getOwnPropertyDescriptor(runtime, 'start');
637
+ const continuableDescriptor = Object.getOwnPropertyDescriptor(runtime, 'startContinuable');
638
+ const guardedStart = async (name, request) => { check(request.parent); return start.call(runtime, name, request); };
639
+ const guardedContinuable = async (spec) => { check(spec.request.parent); return continuable.call(runtime, spec); };
640
+ runtime.start = guardedStart;
641
+ runtime.startContinuable = guardedContinuable;
642
+ return () => {
643
+ active = false;
644
+ for (const [key, fn, descriptor] of [['start', guardedStart, startDescriptor], ['startContinuable', guardedContinuable, continuableDescriptor]]) {
645
+ if (Object.getOwnPropertyDescriptor(runtime, key)?.value !== fn)
646
+ continue;
647
+ if (descriptor === undefined)
648
+ Reflect.deleteProperty(runtime, key);
649
+ else
650
+ Object.defineProperty(runtime, key, descriptor);
651
+ }
652
+ };
653
+ }, 'agent-teams: member delegation budget');
654
+ }
581
655
  /**
582
656
  * Snapshot the real driver activity for durable member ids.
583
657
  *