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

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 (45) hide show
  1. package/README.md +35 -13
  2. package/README_ZH.md +35 -13
  3. package/compatibility.json +6 -2
  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 +139 -24
  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 +355 -82
  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 +282 -13
  22. package/lib/tool-policies.js +187 -0
  23. package/lib/tools.js +382 -109
  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 +78 -15
  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 +113 -2
  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 +100 -0
  39. package/lib/types/workspace-audit.d.ts +27 -0
  40. package/lib/workspace-audit.js +52 -0
  41. package/package.json +61 -54
  42. package/pnpm-workspace.yaml +304 -273
  43. package/release-notes/v0.1.23.md +34 -0
  44. package/release-notes/v0.1.24-rc.1.md +13 -0
  45. package/scripts/legacy-session-source-repair.mjs +344 -0
package/lib/members.js CHANGED
@@ -16,9 +16,10 @@ 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, memberToolFilter, queueMemberPrompt, restrictableToolNames, startMemberWithLenientFilter, steerMemberPrompt, sessionOwnEvents } from "./harness-compat.js";
19
+ import { guardSubagentCreation, startMember, usesManagedActivations, 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) {
368
+ throw new Error('cannot resolve the member LLM route from the current captain session');
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) {
225
380
  throw new Error('cannot resolve the member LLM route from the current captain session');
226
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;
@@ -261,6 +478,7 @@ export async function resolveMemberLlmSelection(ctx, captain, request, signal) {
261
478
  */
262
479
  export function installMemberSelectionRuntime(ctx, stateDir, onFailureSettled) {
263
480
  const pending = new Map();
481
+ const pendingSetup = new Map();
264
482
  installContinuableMemberSetup(ctx, (childCtx, child) => {
265
483
  const descriptor = foldSubagentDescriptor(sessionOwnEvents(child.session));
266
484
  if (descriptor?.mode !== 'continuable' || !descriptor.label.startsWith(MEMBER_LABEL_PREFIX)) {
@@ -293,11 +511,47 @@ export function installMemberSelectionRuntime(ctx, stateDir, onFailureSettled) {
293
511
  return { kind: 'reject' };
294
512
  return next();
295
513
  });
514
+ // New activations no longer persist persona/toolFilter in host descriptors.
515
+ // Install them before the first request, and restore the exact saved values
516
+ // after a cold resume. Historical descriptors remain owned by Harness.
517
+ const savedTeam = pending.has(key) ? undefined : readTeamSync(stateRoot, teamId);
518
+ const savedMember = savedTeam?.captainSessionId === parentSessionId
519
+ ? savedTeam.members.find(member => member.id === child.id && member.name === memberName)
520
+ : undefined;
521
+ const setup = pendingSetup.get(key) ?? savedMember?.runtimeSetup;
522
+ if (usesManagedActivations(ctx.subagents) && savedMember !== undefined
523
+ && setup === undefined && descriptor.toolFilter === undefined) {
524
+ disposeAdmission();
525
+ throw new Error('agent-teams: saved member runtime setup is missing; refusing an unrestricted resume');
526
+ }
527
+ let disposeSetup = () => undefined;
528
+ if (usesManagedActivations(ctx.subagents) && setup !== undefined) {
529
+ try {
530
+ const stopFilter = childCtx.tools.restrict(setup.toolFilter);
531
+ try {
532
+ const stopPersona = childCtx.systemPrompt.section({
533
+ name: 'deployment:persona-prefix',
534
+ order: childCtx.systemPrompt.getSectionOrder('DEPLOYMENT_PERSONA_PREFIX'),
535
+ text: setup.persona,
536
+ });
537
+ disposeSetup = () => { stopPersona(); stopFilter(); };
538
+ }
539
+ catch (error) {
540
+ stopFilter();
541
+ throw error;
542
+ }
543
+ }
544
+ catch (error) {
545
+ disposeAdmission();
546
+ throw error;
547
+ }
548
+ }
549
+ const cleanupAdmission = () => { disposeSetup(); disposeAdmission(); };
296
550
  let selection = pending.get(key);
297
551
  if (selection === undefined) {
298
552
  const team = readTeamSync(stateRoot, teamId);
299
553
  if (team?.captainSessionId !== parentSessionId)
300
- return disposeAdmission;
554
+ return cleanupAdmission;
301
555
  const durableMember = team.members.find(member => member.name === memberName);
302
556
  selection = selectionFromMember(durableMember);
303
557
  if (selection !== undefined && (descriptor.agentProvider !== durableMember?.provider || descriptor.agentModel !== durableMember?.model)) {
@@ -354,7 +608,7 @@ export function installMemberSelectionRuntime(ctx, stateDir, onFailureSettled) {
354
608
  });
355
609
  // Legacy teams still need failure reporting even without a saved route.
356
610
  if (selection === undefined)
357
- return () => { disposeFailure(); disposeAdmission(); };
611
+ return () => { disposeFailure(); cleanupAdmission(); };
358
612
  const selectionRef = { current: modelSelection(selection), assembled: undefined };
359
613
  const disposeSelection = installModelSelection(childCtx, selectionRef);
360
614
  const fallback = selection.fallback;
@@ -384,7 +638,7 @@ export function installMemberSelectionRuntime(ctx, stateDir, onFailureSettled) {
384
638
  disposeFallback();
385
639
  disposeSelection();
386
640
  disposeFailure();
387
- disposeAdmission();
641
+ cleanupAdmission();
388
642
  };
389
643
  });
390
644
  return {
@@ -394,17 +648,20 @@ export function installMemberSelectionRuntime(ctx, stateDir, onFailureSettled) {
394
648
  return parent !== undefined && descriptor?.mode === 'continuable'
395
649
  && pending.has(pendingSelectionKey(parent, descriptor.label));
396
650
  },
397
- async withPending(parentSessionId, label, selection, operation) {
651
+ async withPending(parentSessionId, label, selection, operation, setup) {
398
652
  const key = pendingSelectionKey(parentSessionId, label);
399
653
  if (pending.has(key)) {
400
654
  throw new Error(`member model selection is already pending for "${label}"`);
401
655
  }
402
656
  pending.set(key, selection);
657
+ if (setup !== undefined)
658
+ pendingSetup.set(key, setup);
403
659
  try {
404
660
  return await operation();
405
661
  }
406
662
  finally {
407
663
  pending.delete(key);
664
+ pendingSetup.delete(key);
408
665
  }
409
666
  },
410
667
  };
@@ -491,8 +748,16 @@ Do not start work until the scheduler or captain assigns a task in this turn.`;
491
748
  * @param member - the member draft whose `id` is filled on success.
492
749
  * @param stateDir - configured state directory (for the persona).
493
750
  * @param signal - caller cancellation, forwarded to the start.
751
+ * @param initialPrompt - first turn text; the welcome message when omitted.
752
+ * @param resolvedFilter - the member's effective tool filter (a selected
753
+ * policy, or the legacy captain-only filter when the member has none). The
754
+ * caller resolves it so `spawnMember` forwards exactly one immutable filter
755
+ * to `startContinuable`.
756
+ * @param relaxableDeny - deny names the lenient retry may drop for this
757
+ * member; the caller narrows it for a policy member so a name the owner
758
+ * declared is never silently removed.
494
759
  */
495
- export async function spawnMember(ctx, config, selections, llmSelection, captain, team, member, stateDir, signal, initialPrompt) {
760
+ export async function spawnMember(ctx, config, selections, llmSelection, captain, team, member, stateDir, signal, initialPrompt, resolvedFilter, relaxableDeny) {
496
761
  // Fail loud at the first use: provider registration is a sibling plugin's
497
762
  // effect and may settle after this plugin mounts. Capability checks here
498
763
  // mirror what startContinuable would reject, with an actionable error.
@@ -504,40 +769,59 @@ export async function spawnMember(ctx, config, selections, llmSelection, captain
504
769
  if (provider.prepareContinuable === undefined) {
505
770
  throw new Error(`agent-teams: provider "${config.provider}" does not support continuable members`);
506
771
  }
507
- if (!provider.capabilities.persona) {
772
+ const legacyCapabilities = provider.capabilities;
773
+ if (!usesManagedActivations(ctx.subagents) && !legacyCapabilities.persona) {
508
774
  throw new Error(`agent-teams: provider "${config.provider}" cannot apply a member persona`);
509
775
  }
510
- if (!provider.capabilities.toolFilter) {
776
+ if (!usesManagedActivations(ctx.subagents) && !legacyCapabilities.toolFilter) {
511
777
  throw new Error(`agent-teams: provider "${config.provider}" cannot restrict captain-only tools for members`);
512
778
  }
513
779
  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) },
780
+ // The legacy filter of a member without a configured tool policy: the
781
+ // captain-only deny list plus the depth entries this host registers.
782
+ const requestedFilter = resolvedFilter ?? memberToolFilter(config.maxDepth, restrictableToolNames(captain));
783
+ // The lenient retry may drop names this host reports unknown; keep the exact
784
+ // filter the host accepted for the durable member setup.
785
+ let acceptedFilter = requestedFilter;
786
+ const start = await startMemberWithLenientFilter(toolFilter => {
787
+ acceptedFilter = toolFilter;
788
+ const setup = { persona: memberPersona(team, member, stateDir, config.executionPrompt), toolFilter };
789
+ return selections.withPending(captain.id, label, llmSelection, () => startMember(ctx.subagents, {
790
+ provider: config.provider,
791
+ label,
792
+ request: {
793
+ prompt: [{ type: 'text', text: initialPrompt ?? memberWelcome(team, member.name) }],
794
+ parent: captain,
795
+ persona: memberPersona(team, member, stateDir, config.executionPrompt),
796
+ // Depth deny entries name host tools; resolve them against the
797
+ // running host's registry so a renamed delegation tool cannot abort
798
+ // the spawn (see memberToolFilter). Depth is still enforced by the
799
+ // parent-chain guard installed at plugin mount. A filter the host
800
+ // still rejects is retried without the names it reported.
801
+ toolFilter,
802
+ agentOptions: {
803
+ provider: llmSelection.provider,
804
+ model: llmSelection.model,
805
+ ...llmSelection.reasoningEffort === undefined
806
+ ? {}
807
+ : { reasoningEffort: ReasoningEffortId(llmSelection.reasoningEffort) },
808
+ },
809
+ // Harness request.maxDepth caps the absolute depth of THIS creation;
810
+ // it is not inherited by later delegations. The guard below enforces
811
+ // the configured member-relative descendant budget instead.
533
812
  },
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)))));
813
+ signal,
814
+ }), setup);
815
+ }, requestedFilter, relaxableDeny);
540
816
  member.id = start.childId;
817
+ if (usesManagedActivations(ctx.subagents)) {
818
+ member.runtimeSetup = { persona: memberPersona(team, member, stateDir, config.executionPrompt), toolFilter: acceptedFilter };
819
+ }
820
+ // Only a policy member carries a persisted filter snapshot; a member without
821
+ // a policy keeps its public record shape. Legacy descriptors or runtimeSetup
822
+ // restore the filter on cold resume, depending on the host generation.
823
+ if (member.toolFilter !== undefined)
824
+ member.toolFilter = { ...acceptedFilter };
541
825
  }
542
826
  /**
543
827
  * Deliver one message to a member as its next FIFO turn. Best effort: a
@@ -605,7 +889,6 @@ export function installRetiredMemberGuard(ctx, stateDir) {
605
889
  }
606
890
  /** Bound all descendant creation, including renamed tools and code-runtime calls. */
607
891
  export function installMemberDelegationGuard(ctx, stateDir, maxDepth) {
608
- const runtime = ctx.subagents;
609
892
  let active = true;
610
893
  const check = (parent) => {
611
894
  if (!active)
@@ -617,7 +900,16 @@ export function installMemberDelegationGuard(ctx, stateDir, maxDepth) {
617
900
  if (descriptor?.label?.startsWith(MEMBER_LABEL_PREFIX)) {
618
901
  const identity = descriptor.label.slice(MEMBER_LABEL_PREFIX.length);
619
902
  const separator = identity.indexOf(':');
620
- const team = readTeamSync(join(ancestor.session.header.cwd ?? process.cwd(), stateDir), identity.slice(0, separator));
903
+ let team;
904
+ try {
905
+ team = readTeamSync(join(ancestor.session.header.cwd ?? process.cwd(), stateDir), identity.slice(0, separator));
906
+ }
907
+ catch (error) {
908
+ // Distinguish "team state unreadable" (e.g. a corrupt team.json) from
909
+ // the guarded rejections below: reject with an actionable message
910
+ // instead of leaking the raw parse error through subagents.start.
911
+ 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)}`);
912
+ }
621
913
  const member = team?.members.find(item => item.id === ancestor.id && item.name === identity.slice(separator + 1));
622
914
  if (member === undefined || member.status === 'removed' || member.stopping === true || team?.halted === true)
623
915
  throw new Error('AgentTeams member is no longer admitting delegated work');
@@ -630,27 +922,8 @@ export function installMemberDelegationGuard(ctx, stateDir, maxDepth) {
630
922
  depth++;
631
923
  }
632
924
  };
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');
925
+ guardSubagentCreation(ctx, check);
926
+ ctx.effect(() => () => { active = false; }, 'agent-teams: delegation guard lifetime');
654
927
  }
655
928
  /**
656
929
  * Snapshot the real driver activity for durable member ids.
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)