@nanmicoder/dsh-agent-teams 0.1.22-rc.1 → 0.1.23

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.
Files changed (46) hide show
  1. package/README.md +35 -12
  2. package/README_ZH.md +35 -12
  3. package/compatibility.json +6 -10
  4. package/lib/artifacts.js +342 -0
  5. package/lib/capabilities.js +6 -4
  6. package/lib/client/ActivityPanel.js +5 -2
  7. package/lib/client/agent-teams-card-definition.js +100 -6
  8. package/lib/client.js +90 -7
  9. package/lib/client.js.map +1 -1
  10. package/lib/command.js +3 -2
  11. package/lib/git-artifact-stamp.json +2 -2
  12. package/lib/harness-compat.js +70 -21
  13. package/lib/index.js +26 -3
  14. package/lib/lineage.js +198 -0
  15. package/lib/mailbox.js +59 -34
  16. package/lib/members.js +301 -53
  17. package/lib/profiles.js +3 -2
  18. package/lib/quality-gates.js +850 -65
  19. package/lib/scheduler.js +22 -3
  20. package/lib/snapshot.js +22 -1
  21. package/lib/state.js +279 -13
  22. package/lib/tool-policies.js +187 -0
  23. package/lib/tools.js +370 -89
  24. package/lib/types/artifacts.d.ts +66 -0
  25. package/lib/types/client/activity-monitor.d.ts +6 -0
  26. package/lib/types/client/agent-teams-card-definition.d.ts +56 -0
  27. package/lib/types/harness-compat.d.ts +56 -14
  28. package/lib/types/index.d.ts +26 -1
  29. package/lib/types/lineage.d.ts +92 -0
  30. package/lib/types/members.d.ts +112 -1
  31. package/lib/types/profiles.d.ts +6 -0
  32. package/lib/types/quality-gates.d.ts +216 -10
  33. package/lib/types/scheduler.d.ts +2 -1
  34. package/lib/types/snapshot.d.ts +20 -0
  35. package/lib/types/state.d.ts +22 -5
  36. package/lib/types/tool-policies.d.ts +119 -0
  37. package/lib/types/tools.d.ts +12 -1
  38. package/lib/types/types.d.ts +95 -0
  39. package/lib/types/workspace-audit.d.ts +27 -0
  40. package/lib/workspace-audit.js +52 -0
  41. package/package.json +59 -52
  42. package/pnpm-workspace.yaml +273 -273
  43. package/release-notes/v0.1.22.md +18 -0
  44. package/release-notes/v0.1.23.md +34 -0
  45. package/scripts/doctor.mjs +4 -4
  46. package/scripts/legacy-session-source-repair.mjs +344 -0
package/lib/members.js CHANGED
@@ -19,6 +19,7 @@ import { join } from 'node:path';
19
19
  import { guardSubagentDelivery, installContinuableMemberSetup, memberToolFilter, queueMemberPrompt, restrictableToolNames, startMemberWithLenientFilter, steerMemberPrompt, sessionOwnEvents } from "./harness-compat.js";
20
20
  import { markMailboxDelivered, appendMailbox, CAPTAIN_KEY, createMessage, readRetiredMemberIds, readTeamSync, readTeam, releaseMailboxDelivery, withTeamLock, writeTeam } from "./state.js";
21
21
  import { mailboxPrompt } from "./mailbox.js";
22
+ import { nearestLiveLead } from "./lineage.js";
22
23
  import { appendTeamEvent, captainSessionOf } from "./events.js";
23
24
  import { TERMINAL_TASK_STATUSES } from "./types.js";
24
25
  /** Persona snapshot of a profile protocol; the full text lives on team.json. */
@@ -32,6 +33,46 @@ export const PERSONA_PROTOCOL_MAX_CHARS = 400;
32
33
  function brandedSessionId(value) {
33
34
  return value;
34
35
  }
36
+ /**
37
+ * Compose one member route from request, plugin configuration and the captain
38
+ * session, without performing any I/O. This is the single place where the
39
+ * precedence between the captain's route and a configured member default is
40
+ * decided, so a configured model can never be silently paired with whichever
41
+ * provider the captain happens to sit on (issue #189).
42
+ *
43
+ * Precedence: explicit request > plugin configuration > captain session. Model
44
+ * ids are used verbatim: a `/` inside a model id is part of that id and is
45
+ * never treated as a provider separator.
46
+ *
47
+ * `derivedProvider` is true only for a bare configured `memberModel` with no
48
+ * provider anywhere, which is the one shape whose owner must come from the
49
+ * provider catalogs.
50
+ * @param request - the captain-supplied and plugin-configured route halves.
51
+ * @param captain - the captain's current session route.
52
+ * @returns the composed route, or `undefined` when a half cannot be determined.
53
+ */
54
+ export function composeMemberRoute(request, captain) {
55
+ const explicitProvider = request.provider?.trim();
56
+ const explicitModel = request.model?.trim();
57
+ const configuredModel = request.defaultModel?.trim();
58
+ const configuredProvider = request.defaultProvider?.trim();
59
+ if (explicitProvider !== undefined && explicitModel === undefined)
60
+ return undefined;
61
+ if (explicitProvider === '' || explicitModel === '' || configuredModel === '' || configuredProvider === '')
62
+ return undefined;
63
+ const providerValue = explicitProvider ?? configuredProvider ?? captain.provider;
64
+ const providerSource = explicitProvider !== undefined ? 'request' : configuredProvider !== undefined ? 'config' : 'captain';
65
+ const modelValue = explicitModel ?? configuredModel ?? captain.model;
66
+ const modelSource = explicitModel !== undefined ? 'request' : configuredModel !== undefined ? 'config' : 'captain';
67
+ return {
68
+ provider: { value: providerValue, source: providerSource },
69
+ model: { value: modelValue, source: modelSource },
70
+ // A named provider is authoritative, and a named model keeps the route the
71
+ // caller asked for. Only a model-only plugin default is allowed to have its
72
+ // owner discovered from the catalogs.
73
+ derivedProvider: providerSource === 'captain' && modelSource === 'config',
74
+ };
75
+ }
35
76
  /**
36
77
  * Validate a resolved roster against every provider catalog before any child
37
78
  * session is created. Catalogs are advisory when empty (some adapters accept
@@ -80,6 +121,88 @@ export function steerCaptainReport(captain, from, content, receipt) {
80
121
  return false;
81
122
  }
82
123
  }
124
+ /**
125
+ * Classify a final member turn failure against the durable record. Pure, so the
126
+ * policy is testable without a lock or a Harness.
127
+ *
128
+ * A failure may only mutate the attempt the plugin actually observed. When that
129
+ * attempt is gone — the member moved on to newer work, the task was
130
+ * reassigned to a newer attempt, or the member generation was replaced — the
131
+ * late error carries no authority and stays silent. When the failure belongs to
132
+ * the current generation but its work already settled, the task and member are
133
+ * left exactly as they are and only a durable captain note is added: otherwise
134
+ * an operator cannot tell a clean run from one whose member's last turn died
135
+ * (429, auth failure, crash) after the delivery landed (issue #205).
136
+ * @param observed - the live member/task capability captured at the error event.
137
+ * @param current - the current durable member record and open task, if any.
138
+ * @returns the disposition for this failure.
139
+ */
140
+ export function classifyMemberFailure(observed, current) {
141
+ const member = current.member;
142
+ // Only the exact member generation observed at the error event may report it.
143
+ // A replaced generation (same name, new session id) or a member record that is
144
+ // gone cannot be failed by that child's late error.
145
+ if (member === undefined || member.id !== observed.memberId)
146
+ return 'ignored';
147
+ const task = current.task;
148
+ if (task === undefined) {
149
+ // No open attempt is owned by this generation any more, so nothing here may
150
+ // be mutated — but the failure itself must still be recorded. `working` is
151
+ // NOT evidence of newer work: `syncMemberStatus` writes it as soon as the
152
+ // turn runs, while a task completed earlier in that same turn leaves no open
153
+ // attempt behind (the reporter's run A). Only a *concrete* superseded open
154
+ // attempt — the branch below — or a replaced generation may stay silent.
155
+ return 'noted_settled';
156
+ }
157
+ if (task.id !== observed.task?.id || task.attemptId !== observed.task?.attemptId
158
+ || task.attempt !== observed.task?.attempt)
159
+ return 'ignored';
160
+ return 'failed_attempt';
161
+ }
162
+ /**
163
+ * The surviving durable record of the capability observed at the error event,
164
+ * or `undefined` when there is nothing honest to say about it.
165
+ *
166
+ * A task is only described as *settled* when its durable status is terminal
167
+ * (`completed`/`failed`/`cancelled`). A non-terminal record — for example a task
168
+ * that was claimed and then unclaimed or reassigned while the error was queued —
169
+ * must fall back to the plain "no open attempt" wording instead of implying a
170
+ * delivery landed.
171
+ */
172
+ export function settledTaskRecord(observed, tasks) {
173
+ if (observed === undefined)
174
+ return undefined;
175
+ const record = tasks.find(candidate => candidate.id === observed.id);
176
+ if (record === undefined)
177
+ return undefined;
178
+ return TERMINAL_TASK_STATUSES.includes(record.status) ? record : undefined;
179
+ }
180
+ /**
181
+ * Build the durable captain note for one recorded failure. Pure, so the wording
182
+ * boundaries are testable without a lock or a Harness.
183
+ * @param memberName - the member that failed.
184
+ * @param summary - the failure message plus its code.
185
+ * @param disposition - what the failure was allowed to do.
186
+ * @param task - the open attempt that was failed, when there was one.
187
+ * @param settledRecord - the terminal record of the observed task, when it exists.
188
+ * @returns the exact message the captain will read.
189
+ */
190
+ export function memberFailureMessage(memberName, summary, disposition, task, settledRecord) {
191
+ const head = `Member "${memberName}" hit an unrecoverable turn failure: ${summary}.`;
192
+ if (disposition === 'failed_attempt' && task !== undefined) {
193
+ return `${head} Task ${task.id} ("${task.subject}") was marked failed; reassign it or retry when ready.`;
194
+ }
195
+ if (settledRecord === undefined) {
196
+ return disposition === 'noted_settled'
197
+ ? `${head} No open attempt was owned; no task state was changed.`
198
+ : `${head} No open attempt was owned.`;
199
+ }
200
+ // Only a completed record supports a claim about delivered work. A failed or
201
+ // cancelled record is described by its actual closed state.
202
+ return settledRecord.status === 'completed'
203
+ ? `${head} Task ${settledRecord.id} ("${settledRecord.subject}") had already completed; its state and output were left unchanged. This member's last turn died after its work landed.`
204
+ : `${head} Task ${settledRecord.id} ("${settledRecord.subject}") was already closed as ${settledRecord.status}; its record was left unchanged. This member's last turn died after that task had closed.`;
205
+ }
83
206
  /** Record a final turn failure, never an intermediate request retry. */
84
207
  export async function failMemberOpenAttempt(ctx, stateRoot, teamId, memberName, failure, fallbackSession, observed) {
85
208
  const summary = `${failure.message} (code ${failure.code})`;
@@ -88,35 +211,42 @@ export async function failMemberOpenAttempt(ctx, stateRoot, teamId, memberName,
88
211
  const team = await readTeam(stateRoot, teamId);
89
212
  if (team === undefined || team.halted === true || team.captainSessionId !== observed.captainSessionId)
90
213
  return;
91
- const member = team.members.find(candidate => candidate.name === memberName
92
- && candidate.id === observed.memberId && candidate.status !== 'removed');
93
- if (member === undefined)
94
- return;
214
+ const member = team.members.find(candidate => candidate.name === memberName && candidate.id === observed.memberId);
95
215
  const task = team.tasks.find(candidate => candidate.assignee === memberName
96
216
  && (candidate.status === 'claimed' || candidate.status === 'in_progress'));
97
217
  // A reassign, completion, or recreated member may win the lock while the
98
218
  // final error is queued. Only the capability observed at that event may fail.
99
- if (task?.id !== observed.task?.id || task?.attemptId !== observed.task?.attemptId
100
- || task?.attempt !== observed.task?.attempt)
101
- return;
102
- if (task === undefined && member.status !== 'working')
219
+ const disposition = classifyMemberFailure(observed, {
220
+ member,
221
+ task,
222
+ observedRecord: observed.task === undefined
223
+ ? undefined
224
+ : team.tasks.find(candidate => candidate.id === observed.task.id),
225
+ });
226
+ if (disposition === 'ignored')
103
227
  return;
104
- if (task !== undefined) {
228
+ if (disposition === 'failed_attempt' && task !== undefined) {
105
229
  task.status = 'failed';
106
230
  task.output = summary;
107
231
  task.updatedAt = Date.now();
108
232
  }
109
- if (ctx.agents.get(brandedSessionId(member.id))?.status !== 'running')
233
+ if (member !== undefined && ctx.agents.get(brandedSessionId(member.id))?.status !== 'running')
110
234
  member.status = 'idle';
235
+ // Only a terminal record of the observed task may be described, and only a
236
+ // completed one supports a claim about delivered work. Anything else — a
237
+ // never-assigned member, or a record left pending/claimed while the error was
238
+ // queued — gets the plain "no open attempt" wording.
239
+ const settledRecord = disposition === 'noted_settled'
240
+ ? settledTaskRecord(observed.task, team.tasks)
241
+ : undefined;
111
242
  const message = {
112
- ...createMessage(memberName, CAPTAIN_KEY, task === undefined
113
- ? `Member "${memberName}" hit an unrecoverable turn failure: ${summary}. No open attempt was owned.`
114
- : `Member "${memberName}" hit an unrecoverable turn failure: ${summary}. Task ${task.id} ("${task.subject}") was marked failed; reassign it or retry when ready.`),
243
+ ...createMessage(memberName, CAPTAIN_KEY, memberFailureMessage(memberName, summary, disposition, task, settledRecord)),
115
244
  deliveryClaimedAt: Date.now(),
116
245
  };
117
- await writeTeam(stateRoot, team);
246
+ if (disposition === 'failed_attempt')
247
+ await writeTeam(stateRoot, team);
118
248
  await appendMailbox(stateRoot, team.id, CAPTAIN_KEY, message);
119
- if (task !== undefined) {
249
+ if (disposition === 'failed_attempt' && task !== undefined) {
120
250
  appendTeamEvent(ctx, captainSessionOf(ctx, team.captainSessionId, fallbackSession), 'agent-teams/task-updated', {
121
251
  teamId, taskId: task.id, status: task.status, assignee: memberName, output: task.output,
122
252
  });
@@ -135,7 +265,7 @@ export async function failMemberOpenAttempt(ctx, stateRoot, teamId, memberName,
135
265
  return false;
136
266
  // Use the same lease/acknowledgment contract as send_message, outside the
137
267
  // team lock: steering can synchronously start another agent turn.
138
- const captain = ctx.agents.get(brandedSessionId(prepared.captainSessionId));
268
+ const captain = nearestLiveLead(ctx, { captainSessionId: prepared.captainSessionId });
139
269
  const delivered = captain !== undefined && steerCaptainReport(captain, memberName, prepared.message.content, mailboxPrompt(teamId, CAPTAIN_KEY, [prepared.message]));
140
270
  await withTeamLock(lockKey, () => delivered
141
271
  ? markMailboxDelivered(stateRoot, teamId, CAPTAIN_KEY, [prepared.message.id])
@@ -194,11 +324,21 @@ function modelSelection(selection) {
194
324
  * An explicit effort overrides either policy; the sentinel "default" also
195
325
  * selects the target model's default. The final effort is validated against
196
326
  * the target model before a child is created.
327
+ *
328
+ * A configured `memberModel` is a route preference, not a blind model-id
329
+ * override: it is paired with `memberLlmProvider` (or its own `provider/model`
330
+ * form) when configured, and when only a bare model id is configured it is
331
+ * adopted by the captain's provider only if that provider actually advertises
332
+ * it. Otherwise the catalog decides: exactly one advertising provider wins,
333
+ * none or several produce an actionable error listing them (issue #189). A
334
+ * provider the caller *named* — explicitly or via configuration — is never
335
+ * silently switched away from.
197
336
  */
198
337
  export async function resolveMemberLlmSelection(ctx, captain, request, signal) {
199
338
  const explicitProvider = request.provider?.trim();
200
339
  const explicitModel = request.model?.trim();
201
340
  const defaultModel = request.defaultModel?.trim();
341
+ const defaultProvider = request.defaultProvider?.trim();
202
342
  const explicitEffort = request.reasoningEffort?.trim();
203
343
  const fallback = request.fallback;
204
344
  if (request.provider !== undefined && explicitProvider === '') {
@@ -210,24 +350,47 @@ export async function resolveMemberLlmSelection(ctx, captain, request, signal) {
210
350
  if (request.defaultModel !== undefined && defaultModel === '') {
211
351
  throw new Error('configured memberModel must not be empty');
212
352
  }
353
+ if (request.defaultProvider !== undefined && defaultProvider === '') {
354
+ throw new Error('configured memberLlmProvider must not be empty');
355
+ }
213
356
  if (request.reasoningEffort !== undefined && explicitEffort === '') {
214
357
  throw new Error('member reasoning effort must not be empty');
215
358
  }
359
+ // An explicit provider needs an explicit model; `memberModel` is used
360
+ // verbatim, so a `/` inside a model id is never a provider separator.
216
361
  if (explicitProvider !== undefined && explicitModel === undefined) {
217
362
  throw new Error('an explicit member LLM provider requires an explicit member model');
218
363
  }
219
364
  const current = captain.session.requestHeader()?.config;
220
365
  const currentProvider = current?.provider ?? captain.options.provider;
221
366
  const currentModel = current?.model ?? captain.options.model;
222
- const provider = explicitProvider ?? currentProvider;
223
- const model = explicitModel ?? defaultModel ?? currentModel;
224
- if (provider === undefined || model === undefined) {
367
+ if (currentProvider === undefined || currentModel === undefined) {
225
368
  throw new Error('cannot resolve the member LLM route from the current captain session');
226
369
  }
370
+ // The plugin-wide default only applies where the caller did not choose a
371
+ // model. `request.defaultModel` belongs to the plugin configuration; a
372
+ // request-level `model` is a user choice and is never re-routed.
373
+ const composed = composeMemberRoute({
374
+ provider: request.provider,
375
+ model: request.model,
376
+ defaultModel: defaultModel,
377
+ defaultProvider: defaultProvider,
378
+ }, { provider: currentProvider, model: currentModel });
379
+ if (composed === undefined) {
380
+ throw new Error('cannot resolve the member LLM route from the current captain session');
381
+ }
382
+ // Only a bare configured `memberModel` may have its owner resolved from the
383
+ // catalogs. A named provider (explicit or configured) is authoritative — a
384
+ // model it does not advertise is a hard error, never a silent switch — and a
385
+ // model the caller named keeps that caller's route.
386
+ const provider = composed.derivedProvider
387
+ ? await resolveMemberModelOwner(ctx, currentProvider, composed.model.value, signal)
388
+ : composed.provider.value;
227
389
  // Effort ids belong to one exact provider/model capability. Preserve the
228
390
  // captain's effort only on the same route; a changed route must resolve its
229
391
  // own default. Explicit effort still wins, while "default" forces that
230
392
  // target-default behavior even when the route did not change.
393
+ const model = composed.model.value;
231
394
  const sameRoute = provider === currentProvider && model === currentModel;
232
395
  const reasoningEffort = explicitEffort === undefined
233
396
  ? sameRoute
@@ -236,13 +399,19 @@ export async function resolveMemberLlmSelection(ctx, captain, request, signal) {
236
399
  : explicitEffort === 'default'
237
400
  ? undefined
238
401
  : ReasoningEffortId(explicitEffort);
239
- const resolved = await ctx.llm.resolveCallConfig({
240
- provider,
241
- model,
242
- ...reasoningEffort === undefined
243
- ? {}
244
- : { reasoningEffort },
245
- }, signal);
402
+ let resolved;
403
+ try {
404
+ resolved = await ctx.llm.resolveCallConfig({
405
+ provider,
406
+ model,
407
+ ...reasoningEffort === undefined
408
+ ? {}
409
+ : { reasoningEffort },
410
+ }, signal);
411
+ }
412
+ catch (error) {
413
+ throw new Error(describeMemberRouteFailure(provider, model, error), { cause: error });
414
+ }
246
415
  return {
247
416
  provider: resolved.provider,
248
417
  model: resolved.model,
@@ -252,6 +421,54 @@ export async function resolveMemberLlmSelection(ctx, captain, request, signal) {
252
421
  ...fallback === undefined ? {} : { fallback },
253
422
  };
254
423
  }
424
+ /**
425
+ * Pick the provider that owns a configured bare model id when the captain's
426
+ * provider does not advertise it. Only an unambiguous owner is accepted: zero
427
+ * advertising providers and several advertising providers both fail with the
428
+ * catalog, because guessing between real providers changes which account and
429
+ * quota the user's members run on (issue #189).
430
+ * @returns the resolved provider id.
431
+ */
432
+ async function resolveMemberModelOwner(ctx, captainProvider, model, signal) {
433
+ // Enumeration is optional: an adapter that cannot list its providers must not
434
+ // turn a resolvable route into a crash. Such a route keeps the captain's
435
+ // provider and lets the durable adapter report the real failure.
436
+ const listProviders = ctx.llm.listProviders;
437
+ if (typeof listProviders !== 'function')
438
+ return captainProvider;
439
+ const owners = [];
440
+ for (const provider of listProviders.call(ctx.llm)) {
441
+ if (signal?.aborted === true)
442
+ throw signal.reason ?? new Error('member model resolution was cancelled');
443
+ const catalog = await ctx.llm.listModels(provider.id);
444
+ if (catalog.length === 0)
445
+ continue;
446
+ if (catalog.some((candidate) => candidate.id === model))
447
+ owners.push(provider.id);
448
+ }
449
+ if (owners.includes(captainProvider))
450
+ return captainProvider;
451
+ if (owners.length === 1)
452
+ return owners[0];
453
+ if (owners.length === 0) {
454
+ throw new Error(`no configured provider advertises the member model "${model}".`
455
+ + ` The captain's provider "${captainProvider}" does not serve it either.`
456
+ + ' Name the owner explicitly with memberLlmProvider, or pass provider + model to agent_teams_add_member.');
457
+ }
458
+ throw new Error(`the member model "${model}" is advertised by ${owners.length} providers (${owners.join(', ')}),`
459
+ + ' so its owner is ambiguous. Name it explicitly with memberLlmProvider, or pass provider + model to agent_teams_add_member.');
460
+ }
461
+ /** Turn a raw adapter failure into an actionable, route-naming error. */
462
+ function describeMemberRouteFailure(provider, model, error) {
463
+ const message = error instanceof Error ? error.message : String(error);
464
+ const code = typeof error === 'object' && error !== null && 'code' in error ? String(error.code) : '';
465
+ const hint = code === 'UNKNOWN_MODEL' || code === 'NO_ADAPTER' || code === 'INVALID_CONFIG'
466
+ ? ` The member route was composed from the captain's session and the plugin configuration;`
467
+ + ' set memberLlmProvider when the configured member model belongs to a'
468
+ + " different provider than the captain's session."
469
+ : '';
470
+ return `cannot use the member LLM route "${provider}/${model}": ${message}${code === '' ? '' : ` (code ${code})`}.${hint}`;
471
+ }
255
472
  /**
256
473
  * Install the member selection bridge for every fresh or cold-resumed
257
474
  * continuable child. Fresh creation reads the pending in-memory selection;
@@ -491,8 +708,16 @@ Do not start work until the scheduler or captain assigns a task in this turn.`;
491
708
  * @param member - the member draft whose `id` is filled on success.
492
709
  * @param stateDir - configured state directory (for the persona).
493
710
  * @param signal - caller cancellation, forwarded to the start.
711
+ * @param initialPrompt - first turn text; the welcome message when omitted.
712
+ * @param resolvedFilter - the member's effective tool filter (a selected
713
+ * policy, or the legacy captain-only filter when the member has none). The
714
+ * caller resolves it so `spawnMember` forwards exactly one immutable filter
715
+ * to `startContinuable`.
716
+ * @param relaxableDeny - deny names the lenient retry may drop for this
717
+ * member; the caller narrows it for a policy member so a name the owner
718
+ * declared is never silently removed.
494
719
  */
495
- export async function spawnMember(ctx, config, selections, llmSelection, captain, team, member, stateDir, signal, initialPrompt) {
720
+ export async function spawnMember(ctx, config, selections, llmSelection, captain, team, member, stateDir, signal, initialPrompt, resolvedFilter, relaxableDeny) {
496
721
  // Fail loud at the first use: provider registration is a sibling plugin's
497
722
  // effect and may settle after this plugin mounts. Capability checks here
498
723
  // mirror what startContinuable would reject, with an actionable error.
@@ -511,33 +736,47 @@ export async function spawnMember(ctx, config, selections, llmSelection, captain
511
736
  throw new Error(`agent-teams: provider "${config.provider}" cannot restrict captain-only tools for members`);
512
737
  }
513
738
  const label = `${MEMBER_LABEL_PREFIX}${team.id}:${member.name}`;
514
- const start = await selections.withPending(captain.id, label, llmSelection, () => (startMemberWithLenientFilter(toolFilter => ctx.subagents.startContinuable({
515
- provider: config.provider,
516
- label,
517
- request: {
518
- prompt: [{ type: 'text', text: initialPrompt ?? memberWelcome(team, member.name) }],
519
- parent: captain,
520
- persona: memberPersona(team, member, stateDir, config.executionPrompt),
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,
527
- agentOptions: {
528
- provider: llmSelection.provider,
529
- model: llmSelection.model,
530
- ...llmSelection.reasoningEffort === undefined
531
- ? {}
532
- : { reasoningEffort: ReasoningEffortId(llmSelection.reasoningEffort) },
739
+ // The legacy filter of a member without a configured tool policy: the
740
+ // captain-only deny list plus the depth entries this host registers.
741
+ const requestedFilter = resolvedFilter ?? memberToolFilter(config.maxDepth, restrictableToolNames(captain));
742
+ // The lenient retry may drop names this host reports unknown; keep the exact
743
+ // filter the host accepted so team.json and the child descriptor agree.
744
+ let acceptedFilter = requestedFilter;
745
+ const start = await selections.withPending(captain.id, label, llmSelection, () => (startMemberWithLenientFilter(toolFilter => {
746
+ acceptedFilter = toolFilter;
747
+ return ctx.subagents.startContinuable({
748
+ provider: config.provider,
749
+ label,
750
+ request: {
751
+ prompt: [{ type: 'text', text: initialPrompt ?? memberWelcome(team, member.name) }],
752
+ parent: captain,
753
+ persona: memberPersona(team, member, stateDir, config.executionPrompt),
754
+ // Depth deny entries name host tools; resolve them against the
755
+ // running host's registry so a renamed delegation tool cannot abort
756
+ // the spawn (see memberToolFilter). Depth is still enforced by the
757
+ // parent-chain guard installed at plugin mount. A filter the host
758
+ // still rejects is retried without the names it reported.
759
+ toolFilter,
760
+ agentOptions: {
761
+ provider: llmSelection.provider,
762
+ model: llmSelection.model,
763
+ ...llmSelection.reasoningEffort === undefined
764
+ ? {}
765
+ : { reasoningEffort: ReasoningEffortId(llmSelection.reasoningEffort) },
766
+ },
767
+ // Harness request.maxDepth caps the absolute depth of THIS creation;
768
+ // it is not inherited by later delegations. The guard below enforces
769
+ // the configured member-relative descendant budget instead.
533
770
  },
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.
537
- },
538
- signal,
539
- }), memberToolFilter(config.maxDepth, restrictableToolNames(captain)))));
771
+ signal,
772
+ });
773
+ }, requestedFilter, relaxableDeny)));
540
774
  member.id = start.childId;
775
+ // Only a policy member carries a persisted filter snapshot; a member without
776
+ // a policy keeps the previous record shape and behavior. The descriptor, not
777
+ // this snapshot, is what reapplies the filter on a cold resume.
778
+ if (member.toolFilter !== undefined)
779
+ member.toolFilter = { ...acceptedFilter };
541
780
  }
542
781
  /**
543
782
  * Deliver one message to a member as its next FIFO turn. Best effort: a
@@ -617,7 +856,16 @@ export function installMemberDelegationGuard(ctx, stateDir, maxDepth) {
617
856
  if (descriptor?.label?.startsWith(MEMBER_LABEL_PREFIX)) {
618
857
  const identity = descriptor.label.slice(MEMBER_LABEL_PREFIX.length);
619
858
  const separator = identity.indexOf(':');
620
- const team = readTeamSync(join(ancestor.session.header.cwd ?? process.cwd(), stateDir), identity.slice(0, separator));
859
+ let team;
860
+ try {
861
+ team = readTeamSync(join(ancestor.session.header.cwd ?? process.cwd(), stateDir), identity.slice(0, separator));
862
+ }
863
+ catch (error) {
864
+ // Distinguish "team state unreadable" (e.g. a corrupt team.json) from
865
+ // the guarded rejections below: reject with an actionable message
866
+ // instead of leaking the raw parse error through subagents.start.
867
+ throw new Error(`AgentTeams delegation guard could not read the team state of "${identity.slice(0, separator)}" (its team.json may be corrupt or unreadable): ${String(error)}`);
868
+ }
621
869
  const member = team?.members.find(item => item.id === ancestor.id && item.name === identity.slice(separator + 1));
622
870
  if (member === undefined || member.status === 'removed' || member.stopping === true || team?.halted === true)
623
871
  throw new Error('AgentTeams member is no longer admitting delegated work');
package/lib/profiles.js CHANGED
@@ -17,7 +17,7 @@ export const MAX_PROFILE_TASKS = 32;
17
17
  export const PROFILE_PROTOCOL_PROMPT_LIMIT = 240;
18
18
  const PROFILE_KEYS = ['description', 'protocol', 'executionPrompt', 'fallback', 'members', 'tasks', 'taskPlanning', 'reviewPolicy'];
19
19
  const REVIEW_POLICY_KEYS = ['requirementsMinRounds', 'requirementsMaxRounds', 'codeMaxRounds', 'maxRepairAttempts', 'requiredReviewers'];
20
- const MEMBER_KEYS = ['name', 'role', 'provider', 'model', 'reasoning_effort', 'executionPrompt', 'fallback'];
20
+ const MEMBER_KEYS = ['name', 'role', 'provider', 'model', 'reasoning_effort', 'executionPrompt', 'fallback', 'tool_policy'];
21
21
  const FALLBACK_KEYS = ['provider', 'model'];
22
22
  const TASK_KEYS = ['id', 'subject', 'description', 'assignee', 'dependencies'];
23
23
  /**
@@ -322,10 +322,11 @@ function normalizeMember(value, path, profileName) {
322
322
  const reasoningEffort = optionalNonEmptyString(raw['reasoning_effort'], `${path}.reasoning_effort`);
323
323
  const executionPrompt = optionalNonEmptyString(raw['executionPrompt'], `${path}.executionPrompt`);
324
324
  const fallback = normalizeFallback(raw['fallback'], `${path}.fallback`);
325
+ const toolPolicy = optionalNonEmptyString(raw['tool_policy'], `${path}.tool_policy`);
325
326
  if (provider !== undefined && model === undefined) {
326
327
  throw new Error(`profile member "${name}" sets provider without model`);
327
328
  }
328
- return omitUndefined({ name, role, provider, model, reasoningEffort, executionPrompt, fallback });
329
+ return omitUndefined({ name, role, provider, model, reasoningEffort, executionPrompt, fallback, toolPolicy });
329
330
  }
330
331
  function normalizeFallback(value, path) {
331
332
  if (value === undefined)