@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.
- package/README.md +35 -12
- package/README_ZH.md +35 -12
- package/compatibility.json +6 -10
- package/lib/artifacts.js +342 -0
- package/lib/capabilities.js +6 -4
- package/lib/client/ActivityPanel.js +5 -2
- package/lib/client/agent-teams-card-definition.js +100 -6
- package/lib/client.js +90 -7
- package/lib/client.js.map +1 -1
- package/lib/command.js +3 -2
- package/lib/git-artifact-stamp.json +2 -2
- package/lib/harness-compat.js +70 -21
- package/lib/index.js +26 -3
- package/lib/lineage.js +198 -0
- package/lib/mailbox.js +59 -34
- package/lib/members.js +301 -53
- package/lib/profiles.js +3 -2
- package/lib/quality-gates.js +850 -65
- package/lib/scheduler.js +22 -3
- package/lib/snapshot.js +22 -1
- package/lib/state.js +279 -13
- package/lib/tool-policies.js +187 -0
- package/lib/tools.js +370 -89
- package/lib/types/artifacts.d.ts +66 -0
- package/lib/types/client/activity-monitor.d.ts +6 -0
- package/lib/types/client/agent-teams-card-definition.d.ts +56 -0
- package/lib/types/harness-compat.d.ts +56 -14
- package/lib/types/index.d.ts +26 -1
- package/lib/types/lineage.d.ts +92 -0
- package/lib/types/members.d.ts +112 -1
- package/lib/types/profiles.d.ts +6 -0
- package/lib/types/quality-gates.d.ts +216 -10
- package/lib/types/scheduler.d.ts +2 -1
- package/lib/types/snapshot.d.ts +20 -0
- package/lib/types/state.d.ts +22 -5
- package/lib/types/tool-policies.d.ts +119 -0
- package/lib/types/tools.d.ts +12 -1
- package/lib/types/types.d.ts +95 -0
- package/lib/types/workspace-audit.d.ts +27 -0
- package/lib/workspace-audit.js +52 -0
- package/package.json +59 -52
- package/pnpm-workspace.yaml +273 -273
- package/release-notes/v0.1.22.md +18 -0
- package/release-notes/v0.1.23.md +34 -0
- package/scripts/doctor.mjs +4 -4
- 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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
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
|
-
|
|
535
|
-
|
|
536
|
-
|
|
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
|
-
|
|
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)
|