@nanmicoder/dsh-agent-teams 0.1.13 → 0.1.14
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 +41 -5
- package/README_ZH.md +18 -5
- package/lib/client/ActivityPanel.js +219 -50
- package/lib/client/StagingPlanEditor.js +493 -0
- package/lib/client/activity-model.js +71 -0
- package/lib/client/activity-monitor.js +1 -0
- package/lib/client/index.js +2 -2
- package/lib/client/locales.js +224 -2
- package/lib/client.js +1775 -241
- package/lib/client.js.map +1 -1
- package/lib/command.js +116 -99
- package/lib/index.js +285 -13
- package/lib/members.js +137 -16
- package/lib/profiles.js +572 -0
- package/lib/quality-gates.js +777 -0
- package/lib/scheduler.js +167 -8
- package/lib/snapshot.js +25 -1
- package/lib/state.js +116 -10
- package/lib/tools.js +1230 -38
- package/lib/types/client/ActivityPanel.d.ts +3 -1
- package/lib/types/client/StagingPlanEditor.d.ts +17 -0
- package/lib/types/client/activity-model.d.ts +67 -0
- package/lib/types/client/activity-monitor.d.ts +14 -1
- package/lib/types/client/locales.d.ts +222 -0
- package/lib/types/command.d.ts +11 -56
- package/lib/types/event-types.d.ts +35 -1
- package/lib/types/index.d.ts +9 -0
- package/lib/types/members.d.ts +48 -3
- package/lib/types/profiles.d.ts +124 -0
- package/lib/types/quality-gates.d.ts +148 -0
- package/lib/types/scheduler.d.ts +44 -0
- package/lib/types/snapshot.d.ts +18 -1
- package/lib/types/state.d.ts +8 -3
- package/lib/types/tools.d.ts +73 -9
- package/lib/types/types.d.ts +118 -0
- package/lib/types.js +11 -0
- package/package.json +10 -4
- package/release-notes/v0.1.14.md +68 -0
package/lib/tools.js
CHANGED
|
@@ -12,10 +12,11 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm';
|
|
|
12
12
|
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
13
13
|
import { join } from 'node:path';
|
|
14
14
|
import { appendTeamEvent, captainSessionOf } from "./events.js";
|
|
15
|
-
import { acknowledgeMailbox, appendMailbox, archiveTeamDir, beginTaskAttempt, CAPTAIN_KEY, createMessage, createTeamDir, findTeamByCaptain, findTeamByParticipant, invalidateTaskAttempt, readUnreadMailbox, recordRetiredMemberIds, releaseMailboxDelivery, readTeam, sanitizeKey, transitionError, unsatisfiedDependencies, withTeamLock, writeTeam, } from "./state.js";
|
|
16
|
-
import { deliverToMember, installRetiredMemberGuard, installMemberSelectionRuntime, interruptMember, memberActivity, resolveMemberLlmSelection, spawnMember, } from "./members.js";
|
|
15
|
+
import { acknowledgeMailbox, appendMailbox, archiveTeamDir, beginTaskAttempt, CAPTAIN_KEY, createMessage, createTeamDir, findTeamByCaptain, findTeamByParticipant, cancelUnfinishedTask, invalidateTaskAttempt, readUnreadMailbox, recordRetiredMemberIds, releaseMailboxDelivery, readTeam, sanitizeKey, transitionError, unsatisfiedDependencies, withTeamLock, writeTeam, removeTeamDir, validateCreateTask, evaluateQualityCompletion, planQualityFollowUp, resumeTeamState, buildCoverageMatrix, canDeclareDelivery, describeQualityLoop, sanitizeReviewAcceptance, sanitizeReviewObjective, taskKindOf, } from "./state.js";
|
|
16
|
+
import { deliverToMember, installRetiredMemberGuard, installMemberSelectionRuntime, interruptMember, memberActivity, resolveMemberLlmSelection, spawnMember, validateMemberLlmSelections, } from "./members.js";
|
|
17
17
|
import { TERMINAL_TASK_STATUSES } from "./types.js";
|
|
18
18
|
import { installTeamScheduler } from "./scheduler.js";
|
|
19
|
+
import { resolveTeamProfile } from "./profiles.js";
|
|
19
20
|
/** The caller agent, or a loud failure for non-agent callers. */
|
|
20
21
|
function requireCaptain(exec) {
|
|
21
22
|
if (!exec.agent) {
|
|
@@ -101,11 +102,67 @@ function requireTask(team, taskId) {
|
|
|
101
102
|
}
|
|
102
103
|
return task;
|
|
103
104
|
}
|
|
105
|
+
function requireStagedTeam(team) {
|
|
106
|
+
if (team.phase !== 'staged') {
|
|
107
|
+
throw new Error(`team "${team.name}" is already running; its plan can no longer be edited`);
|
|
108
|
+
}
|
|
109
|
+
if (team.halted === true)
|
|
110
|
+
throw new Error(`team "${team.name}" is halted, not awaiting plan approval`);
|
|
111
|
+
}
|
|
112
|
+
function trimmedOptional(value) {
|
|
113
|
+
const trimmed = value?.trim();
|
|
114
|
+
return trimmed === undefined || trimmed === '' ? undefined : trimmed;
|
|
115
|
+
}
|
|
116
|
+
/** Validate references and cycles before a staged graph can be saved or run. */
|
|
117
|
+
function validateStagedGraph(team, requireRunnable) {
|
|
118
|
+
const members = team.members.filter((member) => member.status !== 'removed');
|
|
119
|
+
if (requireRunnable && members.length === 0)
|
|
120
|
+
throw new Error('add at least one member before approving the plan');
|
|
121
|
+
if (requireRunnable && team.tasks.length === 0)
|
|
122
|
+
throw new Error('add at least one task before approving the plan');
|
|
123
|
+
const memberNames = new Set(members.map((member) => member.name));
|
|
124
|
+
const taskIds = new Set(team.tasks.map((task) => task.id));
|
|
125
|
+
for (const task of team.tasks) {
|
|
126
|
+
if (task.subject.trim() === '')
|
|
127
|
+
throw new Error(`task "${task.id}" must have a subject`);
|
|
128
|
+
if (task.assignee !== undefined && task.assignee !== CAPTAIN_KEY && !memberNames.has(task.assignee)) {
|
|
129
|
+
throw new Error(`task "${task.id}" assignee "${task.assignee}" is not an active member`);
|
|
130
|
+
}
|
|
131
|
+
for (const dependency of task.dependencies) {
|
|
132
|
+
if (dependency === task.id)
|
|
133
|
+
throw new Error(`task "${task.id}" cannot depend on itself`);
|
|
134
|
+
if (!taskIds.has(dependency))
|
|
135
|
+
throw new Error(`task "${task.id}" depends on unknown task "${dependency}"`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
const visiting = new Set();
|
|
139
|
+
const visited = new Set();
|
|
140
|
+
const byId = new Map(team.tasks.map((task) => [task.id, task]));
|
|
141
|
+
const visit = (taskId) => {
|
|
142
|
+
if (visiting.has(taskId))
|
|
143
|
+
throw new Error(`task dependency graph contains a cycle at "${taskId}"`);
|
|
144
|
+
if (visited.has(taskId))
|
|
145
|
+
return;
|
|
146
|
+
visiting.add(taskId);
|
|
147
|
+
for (const dependency of byId.get(taskId)?.dependencies ?? [])
|
|
148
|
+
visit(dependency);
|
|
149
|
+
visiting.delete(taskId);
|
|
150
|
+
visited.add(taskId);
|
|
151
|
+
};
|
|
152
|
+
for (const task of team.tasks)
|
|
153
|
+
visit(task.id);
|
|
154
|
+
}
|
|
104
155
|
function memberOpenTask(team, memberName, exceptTaskId) {
|
|
105
156
|
return team.tasks.find(task => task.id !== exceptTaskId
|
|
106
157
|
&& task.assignee === memberName
|
|
107
158
|
&& (task.status === 'claimed' || task.status === 'in_progress'));
|
|
108
159
|
}
|
|
160
|
+
/** Captain work is immediate, not a durable scheduler lane: allow one unfinished takeover at a time. */
|
|
161
|
+
function captainOpenTask(team, exceptTaskId) {
|
|
162
|
+
return team.tasks.find(task => task.id !== exceptTaskId
|
|
163
|
+
&& task.assignee === CAPTAIN_KEY
|
|
164
|
+
&& !TERMINAL_TASK_STATUSES.includes(task.status));
|
|
165
|
+
}
|
|
109
166
|
async function waitForMemberIdle(ctx, member, signal) {
|
|
110
167
|
if (member.id === '')
|
|
111
168
|
return;
|
|
@@ -126,14 +183,98 @@ async function waitForMemberIdle(ctx, member, signal) {
|
|
|
126
183
|
signal.removeEventListener('abort', onAbort);
|
|
127
184
|
}
|
|
128
185
|
}
|
|
129
|
-
/**
|
|
130
|
-
* Deliver a durable member report at the captain's nearest model boundary.
|
|
186
|
+
/** Stop every currently-resident member activation for one halted team.
|
|
131
187
|
*
|
|
132
|
-
*
|
|
133
|
-
*
|
|
134
|
-
*
|
|
135
|
-
* captain
|
|
188
|
+
* Interrupt requests only cancel the member's current model turn and retain its
|
|
189
|
+
* activation. Draining the selected direct children is the stronger lifecycle
|
|
190
|
+
* boundary: it waits for the activation handles to release, so a child cannot
|
|
191
|
+
* keep executing after the captain-chat Stop control has reported success.
|
|
136
192
|
*/
|
|
193
|
+
async function stopTeamMemberActivations(ctx, captain, members, signal) {
|
|
194
|
+
const activeMembers = members.filter((member) => member.id !== '' && member.status !== 'removed');
|
|
195
|
+
const memberIds = activeMembers.map((member) => member.id);
|
|
196
|
+
if (memberIds.length === 0)
|
|
197
|
+
return;
|
|
198
|
+
for (const memberId of memberIds)
|
|
199
|
+
interruptMember(ctx, captain, memberId);
|
|
200
|
+
// `drainContinuableChildren` is available in the current runtime and releases
|
|
201
|
+
// the selected activation handles. Keep the quiescence fallback for pre-rc.8
|
|
202
|
+
// hosts, where interrupt is the strongest available lifecycle operation.
|
|
203
|
+
const runtime = ctx.subagents;
|
|
204
|
+
if (runtime.drainContinuableChildren !== undefined) {
|
|
205
|
+
try {
|
|
206
|
+
await runtime.drainContinuableChildren(captain, memberIds);
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
catch (error) {
|
|
210
|
+
// Do not claim the browser action stopped work when the runtime could not
|
|
211
|
+
// release all selected child activations. The HTTP route surfaces this
|
|
212
|
+
// failure instead of returning a false successful stop.
|
|
213
|
+
ctx.logger.warn(`agent-teams: failed to drain halted members: ${String(error)}`);
|
|
214
|
+
throw error;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
const fallbackSignal = signal ?? new AbortController().signal;
|
|
218
|
+
const results = await Promise.allSettled(activeMembers.map((member) => waitForMemberIdle(ctx, member, fallbackSignal)));
|
|
219
|
+
const failed = results.find((result) => result.status === 'rejected');
|
|
220
|
+
if (failed?.status === 'rejected')
|
|
221
|
+
throw failed.reason;
|
|
222
|
+
}
|
|
223
|
+
export async function haltTeamWork(input) {
|
|
224
|
+
const halted = await withTeamLock(teamLockKey(input.stateRoot, input.teamId), async () => {
|
|
225
|
+
const fresh = await requireFreshCaptainTeam(input.stateRoot, input.teamId, input.captain.id);
|
|
226
|
+
if (fresh.halted === true) {
|
|
227
|
+
return {
|
|
228
|
+
teamName: fresh.name,
|
|
229
|
+
cancelledTasks: fresh.tasks.filter((task) => task.status === 'cancelled').length,
|
|
230
|
+
alreadyHalted: true,
|
|
231
|
+
members: fresh.members.filter((member) => member.id !== '' && member.status !== 'removed').map((member) => ({ ...member })),
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
const now = Date.now();
|
|
235
|
+
let cancelledTasks = 0;
|
|
236
|
+
for (const task of fresh.tasks) {
|
|
237
|
+
if (TERMINAL_TASK_STATUSES.includes(task.status))
|
|
238
|
+
continue;
|
|
239
|
+
cancelUnfinishedTask(task, 'Stopped from the captain chat.');
|
|
240
|
+
cancelledTasks += 1;
|
|
241
|
+
}
|
|
242
|
+
for (const member of fresh.members) {
|
|
243
|
+
if (member.status === 'removed')
|
|
244
|
+
continue;
|
|
245
|
+
member.status = 'idle';
|
|
246
|
+
}
|
|
247
|
+
fresh.halted = true;
|
|
248
|
+
fresh.haltedAt = now;
|
|
249
|
+
await writeTeam(input.stateRoot, fresh);
|
|
250
|
+
appendTeamEvent(input.ctx, captainSessionOf(input.ctx, fresh.captainSessionId, input.captain.session), 'agent-teams/team-halted', {
|
|
251
|
+
teamId: fresh.id,
|
|
252
|
+
cancelledTasks,
|
|
253
|
+
});
|
|
254
|
+
return {
|
|
255
|
+
teamName: fresh.name,
|
|
256
|
+
cancelledTasks,
|
|
257
|
+
alreadyHalted: false,
|
|
258
|
+
members: fresh.members.filter((member) => member.id !== '' && member.status !== 'removed').map((member) => ({ ...member })),
|
|
259
|
+
};
|
|
260
|
+
});
|
|
261
|
+
// Persist the stop boundary first, then abort the Captain before draining
|
|
262
|
+
// children. Otherwise its current model turn can observe `halted`, call
|
|
263
|
+
// resume, and race the still-running HTTP stop request.
|
|
264
|
+
input.captain.cancel({ kind: 'user' }, { keepInbox: true });
|
|
265
|
+
await stopTeamMemberActivations(input.ctx, input.captain, halted.members, input.signal);
|
|
266
|
+
// Interrupting a child emits a trailing subagent-settled notification. That
|
|
267
|
+
// notification can start a fresh Captain turn after the first cancellation,
|
|
268
|
+
// so close the stop boundary again once every child activation has drained.
|
|
269
|
+
// Queued user input is preserved both times; only runtime-generated work is
|
|
270
|
+
// prevented from silently resuming the halted team.
|
|
271
|
+
input.captain.cancel({ kind: 'user' }, { keepInbox: true });
|
|
272
|
+
return {
|
|
273
|
+
teamName: halted.teamName,
|
|
274
|
+
cancelledTasks: halted.cancelledTasks,
|
|
275
|
+
alreadyHalted: halted.alreadyHalted,
|
|
276
|
+
};
|
|
277
|
+
}
|
|
137
278
|
export function steerCaptainReport(captain, from, content) {
|
|
138
279
|
try {
|
|
139
280
|
captain.steer(createUserMessage({
|
|
@@ -147,6 +288,24 @@ export function steerCaptainReport(captain, from, content) {
|
|
|
147
288
|
return false;
|
|
148
289
|
}
|
|
149
290
|
}
|
|
291
|
+
/** Context queued after the human rejects a staged plan. */
|
|
292
|
+
export function stagedPlanDiscardContext(teamName) {
|
|
293
|
+
return [
|
|
294
|
+
`The user discarded the staged AgentTeams plan "${teamName}" from the pre-run review UI.`,
|
|
295
|
+
'That decision is final for this draft: it has been archived, no members were created, and no tasks may run.',
|
|
296
|
+
'Do not call agent_teams_create, agent_teams_approve, or recreate a replacement team merely because the old team is no longer active.',
|
|
297
|
+
'Wait for a later explicit user request. If the next user message is unrelated to AgentTeams, answer it normally and do not start a team.',
|
|
298
|
+
].join('\n');
|
|
299
|
+
}
|
|
300
|
+
/** Model-facing continuation that turns the review UI back into a conversation. */
|
|
301
|
+
export function stagedPlanFeedbackContext(teamName) {
|
|
302
|
+
return [
|
|
303
|
+
`The user selected "Return to chat and revise" for the staged AgentTeams plan "${teamName}".`,
|
|
304
|
+
'The existing staged plan is still the only draft. Do not create a replacement team, approve it, spawn members, edit the plan, or start work in this turn.',
|
|
305
|
+
'Ask the user one concise, concrete question about what they want changed, then stop and wait for their answer.',
|
|
306
|
+
'After the user answers, revise this same staged roster and DAG with one atomic agent_teams_edit_plan call, summarize the changes, and ask the user to review the updated plan again.',
|
|
307
|
+
].join('\n');
|
|
308
|
+
}
|
|
150
309
|
/**
|
|
151
310
|
* Register every `agent_teams_*` tool into the shared tools registry.
|
|
152
311
|
* @param ctx - the plugin context (injects `tools`).
|
|
@@ -155,13 +314,246 @@ export function steerCaptainReport(captain, from, content) {
|
|
|
155
314
|
export function registerAgentTeamsTools(ctx, config) {
|
|
156
315
|
installRetiredMemberGuard(ctx, config.stateDir);
|
|
157
316
|
const memberSelections = installMemberSelectionRuntime(ctx, config.stateDir);
|
|
158
|
-
const scheduler = installTeamScheduler(ctx, { stateDir: config.stateDir });
|
|
317
|
+
const scheduler = installTeamScheduler(ctx, { stateDir: config.stateDir, executionPrompt: config.executionPrompt });
|
|
318
|
+
const updateStagedPlanBatch = async (captain, teamId, mutations, signal) => {
|
|
319
|
+
if (mutations.length === 0)
|
|
320
|
+
throw new Error('at least one staged plan operation is required');
|
|
321
|
+
const workspace = workspaceOf(captain);
|
|
322
|
+
const stateRoot = stateRootOf(workspace, config);
|
|
323
|
+
return withTeamLock(teamLockKey(stateRoot, teamId), async () => {
|
|
324
|
+
const fresh = await requireFreshCaptainTeam(stateRoot, teamId, captain.id);
|
|
325
|
+
requireStagedTeam(fresh);
|
|
326
|
+
for (const mutation of mutations) {
|
|
327
|
+
if (mutation.action === 'update_member') {
|
|
328
|
+
const member = requireMember(fresh, mutation.memberName);
|
|
329
|
+
if (member.id !== '')
|
|
330
|
+
throw new Error(`staged member "${member.name}" was already spawned`);
|
|
331
|
+
const selection = await resolveMemberLlmSelection(ctx, captain, {
|
|
332
|
+
provider: mutation.provider,
|
|
333
|
+
model: mutation.model,
|
|
334
|
+
reasoningEffort: trimmedOptional(mutation.reasoningEffort),
|
|
335
|
+
fallback: member.fallback,
|
|
336
|
+
}, signal);
|
|
337
|
+
member.role = trimmedOptional(mutation.role);
|
|
338
|
+
member.provider = selection.provider;
|
|
339
|
+
member.model = selection.model;
|
|
340
|
+
member.reasoningEffort = selection.reasoningEffort;
|
|
341
|
+
member.executionPrompt = trimmedOptional(mutation.executionPrompt);
|
|
342
|
+
}
|
|
343
|
+
else if (mutation.action === 'update_task') {
|
|
344
|
+
const task = requireTask(fresh, mutation.taskId);
|
|
345
|
+
if (task.status !== 'pending' || (task.attempt ?? 0) !== 0) {
|
|
346
|
+
throw new Error(`task "${task.id}" has already started and cannot be edited`);
|
|
347
|
+
}
|
|
348
|
+
const subject = mutation.subject.trim();
|
|
349
|
+
if (subject === '')
|
|
350
|
+
throw new Error('task subject must not be empty');
|
|
351
|
+
task.subject = subject;
|
|
352
|
+
task.description = trimmedOptional(mutation.description);
|
|
353
|
+
task.assignee = trimmedOptional(mutation.assignee);
|
|
354
|
+
task.dependencies = [...new Set(mutation.dependencies.map((item) => item.trim()).filter(Boolean))];
|
|
355
|
+
task.updatedAt = Date.now();
|
|
356
|
+
}
|
|
357
|
+
else if (mutation.action === 'add_task') {
|
|
358
|
+
const subject = mutation.subject.trim();
|
|
359
|
+
if (subject === '')
|
|
360
|
+
throw new Error('task subject must not be empty');
|
|
361
|
+
fresh.taskSeq += 1;
|
|
362
|
+
const now = Date.now();
|
|
363
|
+
fresh.tasks.push({
|
|
364
|
+
id: `t${fresh.taskSeq}`,
|
|
365
|
+
subject,
|
|
366
|
+
description: trimmedOptional(mutation.description),
|
|
367
|
+
status: 'pending',
|
|
368
|
+
assignee: trimmedOptional(mutation.assignee),
|
|
369
|
+
dependencies: [...new Set(mutation.dependencies.map((item) => item.trim()).filter(Boolean))],
|
|
370
|
+
attempt: 0,
|
|
371
|
+
kind: 'work',
|
|
372
|
+
createdAt: now,
|
|
373
|
+
updatedAt: now,
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
else if (mutation.action === 'remove_task') {
|
|
377
|
+
const task = requireTask(fresh, mutation.taskId);
|
|
378
|
+
const dependent = fresh.tasks.find((candidate) => candidate.dependencies.includes(task.id));
|
|
379
|
+
if (dependent !== undefined) {
|
|
380
|
+
throw new Error(`task "${task.id}" is still required by "${dependent.id}"; update that dependency before removing the task`);
|
|
381
|
+
}
|
|
382
|
+
fresh.tasks = fresh.tasks.filter((candidate) => candidate.id !== task.id);
|
|
383
|
+
}
|
|
384
|
+
else {
|
|
385
|
+
const member = requireMember(fresh, mutation.memberName);
|
|
386
|
+
if (member.id !== '')
|
|
387
|
+
throw new Error(`staged member "${member.name}" was already spawned`);
|
|
388
|
+
const owned = fresh.tasks.filter((task) => task.assignee === member.name);
|
|
389
|
+
if (owned.length > 0) {
|
|
390
|
+
throw new Error(`member "${member.name}" still owns planned tasks: ${owned.map((task) => task.id).join(', ')}; update or remove those tasks first`);
|
|
391
|
+
}
|
|
392
|
+
fresh.members = fresh.members.filter((candidate) => candidate !== member);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
validateStagedGraph(fresh, false);
|
|
396
|
+
fresh.planReviewState = 'awaiting_review';
|
|
397
|
+
await writeTeam(stateRoot, fresh);
|
|
398
|
+
return fresh;
|
|
399
|
+
});
|
|
400
|
+
};
|
|
401
|
+
const updateStagedPlan = async (captain, teamId, mutation, signal) => (updateStagedPlanBatch(captain, teamId, [mutation], signal));
|
|
402
|
+
const approveStagedTeam = async (captain, teamId, signal) => {
|
|
403
|
+
const workspace = workspaceOf(captain);
|
|
404
|
+
const stateRoot = stateRootOf(workspace, config);
|
|
405
|
+
const runSignal = signal ?? new AbortController().signal;
|
|
406
|
+
const approved = await withTeamLock(teamLockKey(stateRoot, teamId), async () => {
|
|
407
|
+
const fresh = await requireFreshCaptainTeam(stateRoot, teamId, captain.id);
|
|
408
|
+
requireStagedTeam(fresh);
|
|
409
|
+
// A staged removal has no child session to retain in history. Drop those
|
|
410
|
+
// placeholders before transitioning to the stricter running shape.
|
|
411
|
+
fresh.members = fresh.members.filter((member) => member.status !== 'removed');
|
|
412
|
+
validateStagedGraph(fresh, true);
|
|
413
|
+
const spawned = [];
|
|
414
|
+
try {
|
|
415
|
+
const selections = new Map();
|
|
416
|
+
for (const member of fresh.members) {
|
|
417
|
+
if (member.id !== '')
|
|
418
|
+
continue;
|
|
419
|
+
const selection = await resolveMemberLlmSelection(ctx, captain, {
|
|
420
|
+
provider: member.provider,
|
|
421
|
+
model: member.model,
|
|
422
|
+
reasoningEffort: member.reasoningEffort,
|
|
423
|
+
fallback: member.fallback,
|
|
424
|
+
}, runSignal);
|
|
425
|
+
selections.set(member, selection);
|
|
426
|
+
member.provider = selection.provider;
|
|
427
|
+
member.model = selection.model;
|
|
428
|
+
member.reasoningEffort = selection.reasoningEffort;
|
|
429
|
+
}
|
|
430
|
+
// This is the approval commit barrier: resolve and validate the whole
|
|
431
|
+
// final roster before spawning even the first durable child.
|
|
432
|
+
await validateMemberLlmSelections(ctx, [...selections.values()], runSignal);
|
|
433
|
+
for (const [member, selection] of selections) {
|
|
434
|
+
await spawnMember(ctx, memberRuntime(config), memberSelections, selection, captain, fresh, member, config.stateDir, runSignal);
|
|
435
|
+
spawned.push(member);
|
|
436
|
+
}
|
|
437
|
+
if (fresh.members.some((member) => member.id === '')) {
|
|
438
|
+
throw new Error('one or more staged members could not be spawned');
|
|
439
|
+
}
|
|
440
|
+
fresh.phase = 'running';
|
|
441
|
+
delete fresh.planReviewState;
|
|
442
|
+
fresh.approvedAt = Date.now();
|
|
443
|
+
await writeTeam(stateRoot, fresh);
|
|
444
|
+
return { teamId: fresh.id, members: fresh.members.length, tasks: fresh.tasks.length };
|
|
445
|
+
}
|
|
446
|
+
catch (error) {
|
|
447
|
+
await recordRetiredMemberIds(stateRoot, spawned.map((member) => member.id)).catch(() => undefined);
|
|
448
|
+
for (const member of spawned) {
|
|
449
|
+
if (member.id !== '')
|
|
450
|
+
interruptMember(ctx, captain, member.id);
|
|
451
|
+
}
|
|
452
|
+
throw error;
|
|
453
|
+
}
|
|
454
|
+
});
|
|
455
|
+
try {
|
|
456
|
+
await scheduler.kickTeam(workspace, teamId, captain);
|
|
457
|
+
}
|
|
458
|
+
catch (error) {
|
|
459
|
+
// Approval is already durably committed. A transient wake-up failure is
|
|
460
|
+
// recoverable by the next status/member lifecycle kick and must not make
|
|
461
|
+
// the UI report that an already-running team failed to approve.
|
|
462
|
+
ctx.logger.warn(`agent-teams: post-approval kick failed for "${teamId}": ${String(error)}`);
|
|
463
|
+
}
|
|
464
|
+
return approved;
|
|
465
|
+
};
|
|
466
|
+
const continueStagedPlanning = async (captain, teamId) => {
|
|
467
|
+
const workspace = workspaceOf(captain);
|
|
468
|
+
const stateRoot = stateRootOf(workspace, config);
|
|
469
|
+
const prepared = await withTeamLock(teamLockKey(stateRoot, teamId), async () => {
|
|
470
|
+
const fresh = await requireFreshCaptainTeam(stateRoot, teamId, captain.id);
|
|
471
|
+
requireStagedTeam(fresh);
|
|
472
|
+
if (fresh.planReviewState === 'awaiting_feedback') {
|
|
473
|
+
return { teamName: fresh.name, alreadyWaiting: true };
|
|
474
|
+
}
|
|
475
|
+
fresh.planReviewState = 'awaiting_feedback';
|
|
476
|
+
await writeTeam(stateRoot, fresh);
|
|
477
|
+
return { teamName: fresh.name, alreadyWaiting: false };
|
|
478
|
+
});
|
|
479
|
+
if (prepared.alreadyWaiting)
|
|
480
|
+
return { teamId, alreadyWaiting: true };
|
|
481
|
+
// End any planning turn that is still producing tool calls. A plugin
|
|
482
|
+
// follow-up submitted after cancellation is queued as the next turn by the
|
|
483
|
+
// Harness Agent contract, so it cannot race ahead and recreate the team.
|
|
484
|
+
captain.cancel({ kind: 'user' }, { keepInbox: true });
|
|
485
|
+
try {
|
|
486
|
+
captain.followup(createUserMessage({
|
|
487
|
+
content: [{ type: 'text', text: stagedPlanFeedbackContext(prepared.teamName) }],
|
|
488
|
+
source: { kind: 'plugin', plugin: 'dsh-agent-teams' },
|
|
489
|
+
}));
|
|
490
|
+
}
|
|
491
|
+
catch (error) {
|
|
492
|
+
// Do not leave the durable UI in a false waiting state when the live
|
|
493
|
+
// Captain disappeared between lookup and delivery.
|
|
494
|
+
await withTeamLock(teamLockKey(stateRoot, teamId), async () => {
|
|
495
|
+
const fresh = await requireFreshCaptainTeam(stateRoot, teamId, captain.id);
|
|
496
|
+
requireStagedTeam(fresh);
|
|
497
|
+
if (fresh.planReviewState === 'awaiting_feedback') {
|
|
498
|
+
fresh.planReviewState = 'awaiting_review';
|
|
499
|
+
await writeTeam(stateRoot, fresh);
|
|
500
|
+
}
|
|
501
|
+
});
|
|
502
|
+
throw error;
|
|
503
|
+
}
|
|
504
|
+
return { teamId, alreadyWaiting: false };
|
|
505
|
+
};
|
|
506
|
+
const discardStagedTeam = async (captain, teamId) => {
|
|
507
|
+
const workspace = workspaceOf(captain);
|
|
508
|
+
const stateRoot = stateRootOf(workspace, config);
|
|
509
|
+
const discarded = await withTeamLock(teamLockKey(stateRoot, teamId), async () => {
|
|
510
|
+
const fresh = await requireFreshCaptainTeam(stateRoot, teamId, captain.id);
|
|
511
|
+
requireStagedTeam(fresh);
|
|
512
|
+
appendTeamEvent(ctx, captainSessionOf(ctx, fresh.captainSessionId, captain.session), 'agent-teams/plan-discarded', {
|
|
513
|
+
teamId: fresh.id,
|
|
514
|
+
});
|
|
515
|
+
// A staged plan owns no child sessions. Archiving releases the captain
|
|
516
|
+
// immediately while retaining the rejected graph for later inspection.
|
|
517
|
+
await archiveTeamDir(stateRoot, fresh.id);
|
|
518
|
+
return { teamId: fresh.id, teamName: fresh.name };
|
|
519
|
+
});
|
|
520
|
+
// Preserve this control fact for the next genuine user turn, then abort the
|
|
521
|
+
// still-running Captain turn. Without both operations a late model step can
|
|
522
|
+
// observe the missing active team and incorrectly create it again.
|
|
523
|
+
try {
|
|
524
|
+
captain.inject(createUserMessage({
|
|
525
|
+
content: [{ type: 'text', text: stagedPlanDiscardContext(discarded.teamName) }],
|
|
526
|
+
source: { kind: 'plugin', plugin: 'dsh-agent-teams' },
|
|
527
|
+
}));
|
|
528
|
+
}
|
|
529
|
+
catch (error) {
|
|
530
|
+
// The archive is already authoritative. Cancellation still prevents a
|
|
531
|
+
// late step from recreating work; failure to park extra context is only a
|
|
532
|
+
// live-delivery warning and must not turn a successful discard into 409.
|
|
533
|
+
ctx.logger.warn(`agent-teams: failed to inject discard context for "${discarded.teamId}": ${String(error)}`);
|
|
534
|
+
}
|
|
535
|
+
captain.cancel({ kind: 'user' }, { keepInbox: true });
|
|
536
|
+
return { teamId: discarded.teamId };
|
|
537
|
+
};
|
|
538
|
+
const runtime = {
|
|
539
|
+
updateStagedPlan,
|
|
540
|
+
updateStagedPlanBatch,
|
|
541
|
+
approveStagedTeam,
|
|
542
|
+
continueStagedPlanning,
|
|
543
|
+
discardStagedTeam,
|
|
544
|
+
};
|
|
159
545
|
ctx.tools.register(defineTool({
|
|
160
546
|
name: 'agent_teams_create',
|
|
161
|
-
description: 'Create a
|
|
547
|
+
description: 'Create a team. Use approval=required for a two-phase plan: members and tasks remain unspawned/unclaimed until the user reviews the Web plan and explicitly approves it. Optional profiles expand their configured roster; seed profiles also expand template tasks, while captain profiles leave the graph for the Captain to design. approval=automatic preserves the legacy immediate-execution path.',
|
|
162
548
|
parameters: {
|
|
163
549
|
name: { type: 'string', required: true, description: 'Name for the new team (used as its stable id).' },
|
|
164
550
|
description: { type: 'string', description: 'Team purpose / the goal the team will work on.' },
|
|
551
|
+
profile: { type: 'string', description: 'Optional configured profile name.' },
|
|
552
|
+
approval: {
|
|
553
|
+
type: 'string',
|
|
554
|
+
enum: ['required', 'automatic'],
|
|
555
|
+
description: 'required stages the plan for explicit user review; automatic starts immediately. Defaults to automatic for API compatibility.',
|
|
556
|
+
},
|
|
165
557
|
},
|
|
166
558
|
output: {
|
|
167
559
|
schema: {
|
|
@@ -171,11 +563,18 @@ export function registerAgentTeamsTools(ctx, config) {
|
|
|
171
563
|
team_id: { type: 'string', required: true },
|
|
172
564
|
team_name: { type: 'string', required: true },
|
|
173
565
|
state_dir: { type: 'string', required: true },
|
|
566
|
+
phase: { type: 'string', required: true },
|
|
567
|
+
profile: { type: 'string' },
|
|
568
|
+
task_planning: { type: 'string' },
|
|
569
|
+
members: { type: 'array', items: { type: 'object', additionalProperties: false, properties: { member_name: { type: 'string', required: true }, member_id: { type: 'string', required: true }, provider: { type: 'string', required: true }, model: { type: 'string', required: true }, reasoning_effort: { type: 'string' }, status: { type: 'string', required: true } } } },
|
|
570
|
+
tasks: { type: 'array', items: { type: 'object', additionalProperties: false, properties: { task_id: { type: 'string', required: true }, seed_id: { type: 'string', required: true }, subject: { type: 'string', required: true }, status: { type: 'string', required: true }, kind: { type: 'string' }, assignee: { type: 'string' }, dependencies: { type: 'array', items: { type: 'string' }, required: true } } } },
|
|
174
571
|
},
|
|
175
572
|
},
|
|
176
573
|
render: (args, value) => [{
|
|
177
574
|
type: 'text',
|
|
178
|
-
text:
|
|
575
|
+
text: value.phase === 'staged'
|
|
576
|
+
? `Team "${value.team_name}" plan created under ${value.state_dir}. It is staged: finish the roster and DAG, then wait for the user to edit and approve it. Do not start or approve it yourself.`
|
|
577
|
+
: `Team "${value.team_name}" created (id ${value.team_id}) under ${value.state_dir}. You are the captain.`,
|
|
179
578
|
}],
|
|
180
579
|
},
|
|
181
580
|
async execute(args, exec) {
|
|
@@ -186,7 +585,12 @@ export function registerAgentTeamsTools(ctx, config) {
|
|
|
186
585
|
if (teamName === '')
|
|
187
586
|
throw new Error('team name must not be empty');
|
|
188
587
|
const teamId = sanitizeKey(teamName);
|
|
189
|
-
|
|
588
|
+
const staged = args.approval === 'required';
|
|
589
|
+
const profileName = args.profile?.trim();
|
|
590
|
+
if (args.profile !== undefined && profileName === '') {
|
|
591
|
+
throw new Error('AgentTeams profile name must not be empty');
|
|
592
|
+
}
|
|
593
|
+
const created = await withTeamLock(captainLockKey(stateRoot, captain.id), async () => {
|
|
190
594
|
const current = await findTeamByParticipant(stateRoot, captain.id);
|
|
191
595
|
if (current !== undefined) {
|
|
192
596
|
const relationship = current.captainSessionId === captain.id ? 'lead' : 'belong to';
|
|
@@ -197,37 +601,276 @@ export function registerAgentTeamsTools(ctx, config) {
|
|
|
197
601
|
if (existing !== undefined) {
|
|
198
602
|
throw new Error(`team id "${teamId}" is taken by another captain — pick a different team name`);
|
|
199
603
|
}
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
604
|
+
if (profileName === undefined) {
|
|
605
|
+
const state = {
|
|
606
|
+
name: teamName,
|
|
607
|
+
id: teamId,
|
|
608
|
+
description: args.description,
|
|
609
|
+
captainSessionId: captain.id,
|
|
610
|
+
createdAt: Date.now(),
|
|
611
|
+
members: [],
|
|
612
|
+
tasks: [],
|
|
613
|
+
taskSeq: 0,
|
|
614
|
+
...staged ? { phase: 'staged', planReviewState: 'awaiting_review' } : {},
|
|
615
|
+
};
|
|
616
|
+
await createTeamDir(stateRoot, state);
|
|
617
|
+
return { committed: true, state };
|
|
618
|
+
}
|
|
619
|
+
return initializeProfileTeam({
|
|
620
|
+
ctx,
|
|
621
|
+
config,
|
|
622
|
+
memberSelections,
|
|
623
|
+
captain,
|
|
624
|
+
exec,
|
|
625
|
+
stateRoot,
|
|
626
|
+
teamName,
|
|
627
|
+
teamId,
|
|
628
|
+
profileName,
|
|
203
629
|
description: args.description,
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
await
|
|
630
|
+
staged,
|
|
631
|
+
});
|
|
632
|
+
});
|
|
633
|
+
});
|
|
634
|
+
if (created.committed) {
|
|
635
|
+
try {
|
|
636
|
+
await scheduler.kickTeam(workspace, created.state.id, captain);
|
|
637
|
+
}
|
|
638
|
+
catch (error) {
|
|
639
|
+
ctx.logger.warn(`agent-teams: post-create kick failed for "${created.state.id}": ${String(error)}`);
|
|
640
|
+
}
|
|
641
|
+
try {
|
|
211
642
|
appendTeamEvent(ctx, captain.session, 'agent-teams/team-created', {
|
|
212
|
-
teamId: state.id,
|
|
643
|
+
teamId: created.state.id,
|
|
213
644
|
captainSessionId: captain.id,
|
|
214
|
-
name: state.name,
|
|
215
|
-
...state.description !== undefined ? { description: state.description } : {},
|
|
645
|
+
name: created.state.name,
|
|
646
|
+
...created.state.description !== undefined ? { description: created.state.description } : {},
|
|
647
|
+
...created.state.profile?.name === undefined ? {} : { profile: created.state.profile.name },
|
|
216
648
|
});
|
|
217
|
-
|
|
218
|
-
|
|
649
|
+
for (const member of created.state.members) {
|
|
650
|
+
appendTeamEvent(ctx, captain.session, 'agent-teams/member-added', {
|
|
651
|
+
teamId: created.state.id,
|
|
652
|
+
memberId: member.id,
|
|
653
|
+
name: member.name,
|
|
654
|
+
...member.role === undefined ? {} : { role: member.role },
|
|
655
|
+
});
|
|
656
|
+
}
|
|
657
|
+
for (const task of created.state.tasks) {
|
|
658
|
+
appendTeamEvent(ctx, captain.session, 'agent-teams/task-created', {
|
|
659
|
+
teamId: created.state.id,
|
|
660
|
+
taskId: task.id,
|
|
661
|
+
subject: task.subject,
|
|
662
|
+
dependencies: task.dependencies,
|
|
663
|
+
...task.assignee === undefined ? {} : { assignee: task.assignee },
|
|
664
|
+
});
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
catch (error) {
|
|
668
|
+
ctx.logger.warn(`agent-teams: post-create events failed for "${created.state.id}": ${String(error)}`);
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
const persisted = await readTeam(stateRoot, created.state.id).catch(() => undefined);
|
|
672
|
+
const snapshot = persisted ?? created.state;
|
|
673
|
+
if (snapshot.profile === undefined) {
|
|
674
|
+
return {
|
|
675
|
+
team_id: snapshot.id,
|
|
676
|
+
team_name: snapshot.name,
|
|
677
|
+
state_dir: join(stateRoot, snapshot.id),
|
|
678
|
+
phase: snapshot.phase ?? 'running',
|
|
679
|
+
};
|
|
680
|
+
}
|
|
681
|
+
return {
|
|
682
|
+
team_id: snapshot.id,
|
|
683
|
+
team_name: snapshot.name,
|
|
684
|
+
state_dir: join(stateRoot, snapshot.id),
|
|
685
|
+
phase: snapshot.phase ?? 'running',
|
|
686
|
+
profile: snapshot.profile.name,
|
|
687
|
+
task_planning: snapshot.profile.taskPlanning ?? 'seed',
|
|
688
|
+
members: snapshot.members.map((member) => ({
|
|
689
|
+
member_name: member.name,
|
|
690
|
+
member_id: member.id,
|
|
691
|
+
provider: member.provider ?? '',
|
|
692
|
+
model: member.model ?? '',
|
|
693
|
+
...member.reasoningEffort === undefined ? {} : { reasoning_effort: member.reasoningEffort },
|
|
694
|
+
status: member.status,
|
|
695
|
+
})),
|
|
696
|
+
tasks: snapshot.tasks.map((task) => ({
|
|
697
|
+
task_id: task.id,
|
|
698
|
+
seed_id: task.profileSeedId ?? '',
|
|
699
|
+
subject: task.subject,
|
|
700
|
+
status: task.status,
|
|
701
|
+
...task.kind === undefined ? {} : { kind: task.kind },
|
|
702
|
+
...task.assignee === undefined ? {} : { assignee: task.assignee },
|
|
703
|
+
dependencies: task.dependencies,
|
|
704
|
+
})),
|
|
705
|
+
};
|
|
706
|
+
},
|
|
707
|
+
}));
|
|
708
|
+
ctx.tools.register(defineTool({
|
|
709
|
+
name: 'agent_teams_edit_plan',
|
|
710
|
+
description: 'Atomically revise the current staged AgentTeams plan without spawning members or scheduling tasks. Use this when the user continues chatting to change a plan that is waiting for approval. Submit dependent edits in order (update downstream dependencies or assignees, then remove tasks, then remove unused members). Never inspect or edit .agent-teams state files or plugin source code to revise a plan.',
|
|
711
|
+
parameters: {
|
|
712
|
+
operations: {
|
|
713
|
+
type: 'array',
|
|
714
|
+
required: true,
|
|
715
|
+
description: 'One atomic, ordered batch of staged-plan edits. If any operation is invalid, none of the edits are saved.',
|
|
716
|
+
items: {
|
|
717
|
+
type: 'object',
|
|
718
|
+
additionalProperties: false,
|
|
719
|
+
properties: {
|
|
720
|
+
action: {
|
|
721
|
+
type: 'string',
|
|
722
|
+
required: true,
|
|
723
|
+
enum: ['update_member', 'update_task', 'add_task', 'remove_task', 'remove_member'],
|
|
724
|
+
},
|
|
725
|
+
member_name: { type: 'string', description: 'Member name for update_member or remove_member.' },
|
|
726
|
+
task_id: { type: 'string', description: 'Task id for update_task or remove_task.' },
|
|
727
|
+
subject: { type: 'string', description: 'Required for add_task; optional replacement for update_task.' },
|
|
728
|
+
description: { type: 'string', description: 'Optional task description.' },
|
|
729
|
+
assignee: { type: 'string', description: 'Optional task assignee; an empty string moves it to the shared pool.' },
|
|
730
|
+
dependencies: { type: 'array', items: { type: 'string' }, description: 'Complete replacement dependency list for a task.' },
|
|
731
|
+
role: { type: 'string', description: 'Optional member role.' },
|
|
732
|
+
provider: { type: 'string', description: 'Optional member provider; defaults to the current staged route.' },
|
|
733
|
+
model: { type: 'string', description: 'Optional member model; defaults to the current staged route.' },
|
|
734
|
+
reasoning_effort: { type: 'string', description: 'Optional member reasoning effort.' },
|
|
735
|
+
execution_prompt: { type: 'string', description: 'Optional member-specific execution prompt.' },
|
|
736
|
+
},
|
|
737
|
+
},
|
|
738
|
+
},
|
|
739
|
+
},
|
|
740
|
+
output: {
|
|
741
|
+
schema: {
|
|
742
|
+
type: 'object',
|
|
743
|
+
additionalProperties: false,
|
|
744
|
+
properties: {
|
|
745
|
+
status: { type: 'string', required: true },
|
|
746
|
+
team_id: { type: 'string', required: true },
|
|
747
|
+
members: { type: 'number', required: true },
|
|
748
|
+
tasks: { type: 'number', required: true },
|
|
749
|
+
dependencies: { type: 'number', required: true },
|
|
750
|
+
roster: { type: 'array', items: { type: 'string' }, required: true },
|
|
751
|
+
graph: { type: 'array', items: { type: 'string' }, required: true },
|
|
752
|
+
},
|
|
753
|
+
},
|
|
754
|
+
render: (_args, value) => [{
|
|
755
|
+
type: 'text',
|
|
756
|
+
text: `Staged plan updated atomically (${value.members} members, ${value.tasks} tasks, ${value.dependencies} dependencies). No members were spawned and no tasks were scheduled.\n${value.graph.join('\n')}`,
|
|
757
|
+
}],
|
|
758
|
+
},
|
|
759
|
+
async execute(args, exec) {
|
|
760
|
+
const captain = requireCaptain(exec);
|
|
761
|
+
const workspace = workspaceOf(captain);
|
|
762
|
+
const team = await requireCaptainTeam(workspace, config, captain);
|
|
763
|
+
requireStagedTeam(team);
|
|
764
|
+
if (args.operations.length === 0)
|
|
765
|
+
throw new Error('at least one staged plan operation is required');
|
|
766
|
+
const mutations = args.operations.map((operation, index) => {
|
|
767
|
+
const label = `operation ${index + 1} (${operation.action})`;
|
|
768
|
+
if (operation.action === 'update_member') {
|
|
769
|
+
const memberName = operation.member_name?.trim() ?? '';
|
|
770
|
+
if (memberName === '')
|
|
771
|
+
throw new Error(`${label} requires member_name`);
|
|
772
|
+
const member = requireMember(team, memberName);
|
|
773
|
+
return {
|
|
774
|
+
action: 'update_member',
|
|
775
|
+
memberName,
|
|
776
|
+
role: operation.role ?? member.role,
|
|
777
|
+
provider: operation.provider?.trim() || member.provider || '',
|
|
778
|
+
model: operation.model?.trim() || member.model || '',
|
|
779
|
+
reasoningEffort: operation.reasoning_effort ?? member.reasoningEffort,
|
|
780
|
+
executionPrompt: operation.execution_prompt ?? member.executionPrompt,
|
|
781
|
+
};
|
|
782
|
+
}
|
|
783
|
+
if (operation.action === 'update_task') {
|
|
784
|
+
const taskId = operation.task_id?.trim() ?? '';
|
|
785
|
+
if (taskId === '')
|
|
786
|
+
throw new Error(`${label} requires task_id`);
|
|
787
|
+
const task = requireTask(team, taskId);
|
|
788
|
+
return {
|
|
789
|
+
action: 'update_task',
|
|
790
|
+
taskId,
|
|
791
|
+
subject: operation.subject ?? task.subject,
|
|
792
|
+
description: operation.description ?? task.description,
|
|
793
|
+
assignee: operation.assignee ?? task.assignee,
|
|
794
|
+
dependencies: operation.dependencies ?? task.dependencies,
|
|
795
|
+
};
|
|
796
|
+
}
|
|
797
|
+
if (operation.action === 'add_task') {
|
|
798
|
+
const subject = operation.subject?.trim() ?? '';
|
|
799
|
+
if (subject === '')
|
|
800
|
+
throw new Error(`${label} requires a non-empty subject`);
|
|
801
|
+
return {
|
|
802
|
+
action: 'add_task',
|
|
803
|
+
subject,
|
|
804
|
+
description: operation.description,
|
|
805
|
+
assignee: operation.assignee,
|
|
806
|
+
dependencies: operation.dependencies ?? [],
|
|
807
|
+
};
|
|
808
|
+
}
|
|
809
|
+
if (operation.action === 'remove_task') {
|
|
810
|
+
const taskId = operation.task_id?.trim() ?? '';
|
|
811
|
+
if (taskId === '')
|
|
812
|
+
throw new Error(`${label} requires task_id`);
|
|
813
|
+
return { action: 'remove_task', taskId };
|
|
814
|
+
}
|
|
815
|
+
const memberName = operation.member_name?.trim() ?? '';
|
|
816
|
+
if (memberName === '')
|
|
817
|
+
throw new Error(`${label} requires member_name`);
|
|
818
|
+
return { action: 'remove_member', memberName };
|
|
219
819
|
});
|
|
820
|
+
const updated = await updateStagedPlanBatch(captain, team.id, mutations, exec.signal);
|
|
821
|
+
return {
|
|
822
|
+
status: 'staged',
|
|
823
|
+
team_id: updated.id,
|
|
824
|
+
members: updated.members.length,
|
|
825
|
+
tasks: updated.tasks.length,
|
|
826
|
+
dependencies: updated.tasks.reduce((sum, task) => sum + task.dependencies.length, 0),
|
|
827
|
+
roster: updated.members.map((member) => `${member.name} (${member.role || 'member'}; ${member.provider ?? ''}/${member.model ?? ''})`),
|
|
828
|
+
graph: updated.tasks.map((task) => `${task.id}: ${task.subject} -> ${task.assignee || 'shared'}${task.dependencies.length === 0 ? '' : `; depends on ${task.dependencies.join(', ')}`}`),
|
|
829
|
+
};
|
|
830
|
+
},
|
|
831
|
+
}));
|
|
832
|
+
ctx.tools.register(defineTool({
|
|
833
|
+
name: 'agent_teams_approve',
|
|
834
|
+
description: 'Approve and start a staged team plan. Call this only in response to an explicit user approval in a new user turn; never call it during the turn that created or edited the plan. The Web Approve & Run button uses the same runtime directly.',
|
|
835
|
+
parameters: {
|
|
836
|
+
confirmation: { type: 'string', required: true, description: 'The user\'s explicit approval statement.' },
|
|
837
|
+
},
|
|
838
|
+
output: {
|
|
839
|
+
schema: {
|
|
840
|
+
type: 'object',
|
|
841
|
+
additionalProperties: false,
|
|
842
|
+
properties: {
|
|
843
|
+
status: { type: 'string', required: true },
|
|
844
|
+
team_id: { type: 'string', required: true },
|
|
845
|
+
members: { type: 'number', required: true },
|
|
846
|
+
tasks: { type: 'number', required: true },
|
|
847
|
+
},
|
|
848
|
+
},
|
|
849
|
+
render: (_args, value) => [{
|
|
850
|
+
type: 'text',
|
|
851
|
+
text: `Team ${value.team_id} approved and running (${value.members} members, ${value.tasks} tasks).`,
|
|
852
|
+
}],
|
|
853
|
+
},
|
|
854
|
+
async execute(args, exec) {
|
|
855
|
+
if (args.confirmation.trim() === '')
|
|
856
|
+
throw new Error('explicit user approval text is required');
|
|
857
|
+
const captain = requireCaptain(exec);
|
|
858
|
+
const workspace = workspaceOf(captain);
|
|
859
|
+
const team = await requireCaptainTeam(workspace, config, captain);
|
|
860
|
+
const approved = await approveStagedTeam(captain, team.id, exec.signal);
|
|
861
|
+
return { status: 'running', team_id: approved.teamId, members: approved.members, tasks: approved.tasks };
|
|
220
862
|
},
|
|
221
863
|
}));
|
|
222
864
|
ctx.tools.register(defineTool({
|
|
223
865
|
name: 'agent_teams_add_member',
|
|
224
|
-
description: 'Add a
|
|
866
|
+
description: 'Add a member to the team roster. In a staged team this only adds an editable plan row and does not spawn a child; approval spawns the final configuration. In a running team it creates the durable continuable member immediately.',
|
|
225
867
|
parameters: {
|
|
226
868
|
name: { type: 'string', required: true, description: 'Unique member name inside the team.' },
|
|
227
869
|
role: { type: 'string', description: 'Role of the member (e.g. researcher, engineer, reviewer).' },
|
|
228
870
|
provider: { type: 'string', description: 'Optional LLM provider route. Use only when the user explicitly requests a different provider; requires model.' },
|
|
229
871
|
model: { type: 'string', description: 'Optional model override. Omit for the captain\'s current model (or the configured memberModel default).' },
|
|
230
872
|
reasoning_effort: { type: 'string', description: 'Optional reasoning effort override: one of the target model\'s supported effort ids, or "default" to force its default. When omitted, the captain\'s effort is inherited only for the same provider/model; a changed route uses the target default.' },
|
|
873
|
+
executionPrompt: { type: 'string', description: 'Optional member-specific execution prompt. It remains editable while staged.' },
|
|
231
874
|
},
|
|
232
875
|
output: {
|
|
233
876
|
schema: {
|
|
@@ -240,11 +883,14 @@ export function registerAgentTeamsTools(ctx, config) {
|
|
|
240
883
|
model: { type: 'string', required: true },
|
|
241
884
|
reasoning_effort: { type: 'string' },
|
|
242
885
|
status: { type: 'string', required: true },
|
|
886
|
+
phase: { type: 'string', required: true },
|
|
243
887
|
},
|
|
244
888
|
},
|
|
245
889
|
render: (args, value) => [{
|
|
246
890
|
type: 'text',
|
|
247
|
-
text:
|
|
891
|
+
text: value.phase === 'staged'
|
|
892
|
+
? `Member "${value.member_name}" added to the staged roster (${value.provider}/${value.model}); no child was spawned.`
|
|
893
|
+
: `Member "${value.member_name}" added (subagent id ${value.member_id}, ${value.provider}/${value.model}${value.reasoning_effort === undefined ? '' : `, reasoning ${value.reasoning_effort}`}, status ${value.status}).`,
|
|
248
894
|
}],
|
|
249
895
|
},
|
|
250
896
|
async execute(args, exec) {
|
|
@@ -272,6 +918,7 @@ export function registerAgentTeamsTools(ctx, config) {
|
|
|
272
918
|
model: args.model,
|
|
273
919
|
defaultModel: config.memberModel,
|
|
274
920
|
reasoningEffort: args.reasoning_effort,
|
|
921
|
+
fallback: config.fallback,
|
|
275
922
|
}, exec.signal);
|
|
276
923
|
const member = {
|
|
277
924
|
id: '',
|
|
@@ -280,10 +927,13 @@ export function registerAgentTeamsTools(ctx, config) {
|
|
|
280
927
|
provider: selection.provider,
|
|
281
928
|
model: selection.model,
|
|
282
929
|
reasoningEffort: selection.reasoningEffort,
|
|
930
|
+
executionPrompt: trimmedOptional(args.executionPrompt),
|
|
283
931
|
joinedAt: Date.now(),
|
|
284
932
|
status: 'idle',
|
|
285
933
|
};
|
|
286
|
-
|
|
934
|
+
if (fresh.phase !== 'staged') {
|
|
935
|
+
await spawnMember(ctx, memberRuntime(config), memberSelections, selection, captain, fresh, member, config.stateDir, exec.signal);
|
|
936
|
+
}
|
|
287
937
|
fresh.members.push(member);
|
|
288
938
|
try {
|
|
289
939
|
await writeTeam(stateRoot, fresh);
|
|
@@ -313,6 +963,7 @@ export function registerAgentTeamsTools(ctx, config) {
|
|
|
313
963
|
? {}
|
|
314
964
|
: { reasoning_effort: selection.reasoningEffort },
|
|
315
965
|
status: member.status,
|
|
966
|
+
phase: fresh.phase ?? 'running',
|
|
316
967
|
};
|
|
317
968
|
});
|
|
318
969
|
await scheduler.kickMember(workspace, team.id, created.member_name, captain);
|
|
@@ -379,9 +1030,9 @@ export function registerAgentTeamsTools(ctx, config) {
|
|
|
379
1030
|
}));
|
|
380
1031
|
ctx.tools.register(defineTool({
|
|
381
1032
|
name: 'agent_teams_create_task',
|
|
382
|
-
description: 'Create a task in your team\'s task list. Tasks can depend on other tasks (dependencies): a task is only claimable once every dependency is completed. Optionally assign it to a member, who still claims it before working.',
|
|
1033
|
+
description: 'Create a task in your team\'s task list. Every call must include a non-empty subject, including verification and review tasks. Tasks can depend on other tasks (dependencies): a task is only claimable once every dependency is completed. Optionally assign it to a member, who still claims it before working.',
|
|
383
1034
|
parameters: {
|
|
384
|
-
subject: { type: 'string', required: true, description: '
|
|
1035
|
+
subject: { type: 'string', required: true, description: 'Required non-empty title for this task. Never omit it, including for verification or review tasks.' },
|
|
385
1036
|
description: { type: 'string', description: 'What needs to be done, in detail.' },
|
|
386
1037
|
dependencies: {
|
|
387
1038
|
type: 'array',
|
|
@@ -389,6 +1040,25 @@ export function registerAgentTeamsTools(ctx, config) {
|
|
|
389
1040
|
description: 'Task ids this task depends on (must be completed before this task can be claimed).',
|
|
390
1041
|
},
|
|
391
1042
|
assignee: { type: 'string', description: 'Optional member name this task is intended for.' },
|
|
1043
|
+
kind: {
|
|
1044
|
+
type: 'string',
|
|
1045
|
+
enum: ['work', 'requirements', 'implementation', 'verification', 'review', 'repair', 'integration'],
|
|
1046
|
+
description: 'Task kind. Defaults to work (legacy, no quality gates). Quality kinds require a contract.',
|
|
1047
|
+
},
|
|
1048
|
+
round: { type: 'number', description: '1-based review / requirements / repair round.' },
|
|
1049
|
+
objective: { type: 'string', description: 'Required non-empty objective for quality kinds.' },
|
|
1050
|
+
inScope: { type: 'array', items: { type: 'string' }, description: 'Workspace-relative POSIX paths this task may change.' },
|
|
1051
|
+
outOfScope: { type: 'array', items: { type: 'string' }, description: 'Workspace-relative POSIX paths this task must not change.' },
|
|
1052
|
+
acceptance: { type: 'array', items: { type: 'string' }, description: 'Acceptance criteria. Required for quality kinds.' },
|
|
1053
|
+
verify: { type: 'array', items: { type: 'string' }, description: 'Verification commands. Required for implementation/repair.' },
|
|
1054
|
+
deliverables: { type: 'array', items: { type: 'string' }, description: 'Expected deliverable paths or names.' },
|
|
1055
|
+
nonGoals: { type: 'array', items: { type: 'string' }, description: 'Explicit non-goals.' },
|
|
1056
|
+
reviewedTaskId: { type: 'string', description: 'Task being reviewed. Required for kind=review.' },
|
|
1057
|
+
sourceTaskId: { type: 'string', description: 'Source implementation/artifact. Required for kind=repair.' },
|
|
1058
|
+
sourceFindingIds: { type: 'array', items: { type: 'string' }, description: 'Finding ids this repair must close.' },
|
|
1059
|
+
coverageOf: { type: 'array', items: { type: 'string' }, description: 'User-constraint / goal items this task covers.' },
|
|
1060
|
+
resume: { type: 'boolean', description: 'If true, clear halted in the same lock before creating the task.' },
|
|
1061
|
+
resumeReason: { type: 'string', description: 'Required non-empty reason when resume=true.' },
|
|
392
1062
|
},
|
|
393
1063
|
output: {
|
|
394
1064
|
schema: {
|
|
@@ -413,6 +1083,41 @@ export function registerAgentTeamsTools(ctx, config) {
|
|
|
413
1083
|
const team = await requireCaptainTeam(workspace, config, captain);
|
|
414
1084
|
const created = await withTeamLock(teamLockKey(stateRoot, team.id), async () => {
|
|
415
1085
|
const fresh = await requireFreshCaptainTeam(stateRoot, team.id, captain.id);
|
|
1086
|
+
const gate = validateCreateTask(fresh, {
|
|
1087
|
+
subject: args.subject,
|
|
1088
|
+
description: args.description,
|
|
1089
|
+
dependencies: args.dependencies,
|
|
1090
|
+
assignee: args.assignee,
|
|
1091
|
+
kind: args.kind,
|
|
1092
|
+
round: args.round,
|
|
1093
|
+
objective: args.objective,
|
|
1094
|
+
inScope: args.inScope,
|
|
1095
|
+
outOfScope: args.outOfScope,
|
|
1096
|
+
acceptance: args.acceptance,
|
|
1097
|
+
verify: args.verify,
|
|
1098
|
+
deliverables: args.deliverables,
|
|
1099
|
+
nonGoals: args.nonGoals,
|
|
1100
|
+
reviewedTaskId: args.reviewedTaskId,
|
|
1101
|
+
sourceTaskId: args.sourceTaskId,
|
|
1102
|
+
sourceFindingIds: args.sourceFindingIds,
|
|
1103
|
+
coverageOf: args.coverageOf,
|
|
1104
|
+
resume: args.resume,
|
|
1105
|
+
resumeReason: args.resumeReason,
|
|
1106
|
+
});
|
|
1107
|
+
if (!gate.ok)
|
|
1108
|
+
throw new Error(gate.error ?? 'create_task rejected by quality gates');
|
|
1109
|
+
if (fresh.halted === true) {
|
|
1110
|
+
const resumed = resumeTeamState(fresh, args.resumeReason ?? '');
|
|
1111
|
+
if (resumed.status !== 'resumed' || resumed.team === undefined) {
|
|
1112
|
+
throw new Error(resumed.error ?? 'team is halted; call agent_teams_resume or pass resume=true with resumeReason');
|
|
1113
|
+
}
|
|
1114
|
+
fresh.halted = false;
|
|
1115
|
+
fresh.haltedAt = undefined;
|
|
1116
|
+
appendTeamEvent(ctx, captainSessionOf(ctx, fresh.captainSessionId, captain.session), 'agent-teams/team-resumed', {
|
|
1117
|
+
teamId: fresh.id,
|
|
1118
|
+
reason: args.resumeReason ?? '',
|
|
1119
|
+
});
|
|
1120
|
+
}
|
|
416
1121
|
const dependencies = args.dependencies ?? [];
|
|
417
1122
|
for (const dependency of dependencies) {
|
|
418
1123
|
if (!fresh.tasks.some((task) => task.id === dependency)) {
|
|
@@ -421,6 +1126,13 @@ export function registerAgentTeamsTools(ctx, config) {
|
|
|
421
1126
|
}
|
|
422
1127
|
if (args.assignee !== undefined)
|
|
423
1128
|
requireMember(fresh, args.assignee);
|
|
1129
|
+
const kind = gate.kind ?? 'work';
|
|
1130
|
+
const objective = kind === 'review' || kind === 'requirements'
|
|
1131
|
+
? sanitizeReviewObjective(args.objective)
|
|
1132
|
+
: args.objective;
|
|
1133
|
+
const acceptance = kind === 'review' || kind === 'requirements'
|
|
1134
|
+
? sanitizeReviewAcceptance(args.acceptance)
|
|
1135
|
+
: args.acceptance;
|
|
424
1136
|
const task = {
|
|
425
1137
|
id: `t${fresh.taskSeq + 1}`,
|
|
426
1138
|
subject: args.subject,
|
|
@@ -431,6 +1143,19 @@ export function registerAgentTeamsTools(ctx, config) {
|
|
|
431
1143
|
attempt: 0,
|
|
432
1144
|
createdAt: Date.now(),
|
|
433
1145
|
updatedAt: Date.now(),
|
|
1146
|
+
kind,
|
|
1147
|
+
...args.round === undefined ? {} : { round: args.round },
|
|
1148
|
+
...objective === undefined ? {} : { objective },
|
|
1149
|
+
...args.inScope === undefined ? {} : { inScope: args.inScope },
|
|
1150
|
+
...args.outOfScope === undefined ? {} : { outOfScope: args.outOfScope },
|
|
1151
|
+
...acceptance === undefined ? {} : { acceptance },
|
|
1152
|
+
...args.verify === undefined ? {} : { verify: args.verify },
|
|
1153
|
+
...args.deliverables === undefined ? {} : { deliverables: args.deliverables },
|
|
1154
|
+
...args.nonGoals === undefined ? {} : { nonGoals: args.nonGoals },
|
|
1155
|
+
...args.reviewedTaskId === undefined ? {} : { reviewedTaskId: args.reviewedTaskId },
|
|
1156
|
+
...args.sourceTaskId === undefined ? {} : { sourceTaskId: args.sourceTaskId },
|
|
1157
|
+
...args.sourceFindingIds === undefined ? {} : { sourceFindingIds: args.sourceFindingIds },
|
|
1158
|
+
...args.coverageOf === undefined ? {} : { coverageOf: args.coverageOf },
|
|
434
1159
|
};
|
|
435
1160
|
fresh.taskSeq += 1;
|
|
436
1161
|
fresh.tasks.push(task);
|
|
@@ -441,6 +1166,8 @@ export function registerAgentTeamsTools(ctx, config) {
|
|
|
441
1166
|
subject: task.subject,
|
|
442
1167
|
dependencies: task.dependencies,
|
|
443
1168
|
...task.assignee !== undefined ? { assignee: task.assignee } : {},
|
|
1169
|
+
...task.kind === undefined ? {} : { kind: task.kind },
|
|
1170
|
+
...task.round === undefined ? {} : { round: task.round },
|
|
444
1171
|
});
|
|
445
1172
|
return {
|
|
446
1173
|
task_id: task.id,
|
|
@@ -455,7 +1182,7 @@ export function registerAgentTeamsTools(ctx, config) {
|
|
|
455
1182
|
}));
|
|
456
1183
|
ctx.tools.register(defineTool({
|
|
457
1184
|
name: 'agent_teams_reassign_task',
|
|
458
|
-
description: 'Atomically retry, reassign, or let the captain take over
|
|
1185
|
+
description: 'Atomically retry, reassign, or let the captain take over one ready unfinished/failed task. The old attempt is revoked before its member is interrupted, so late updates cannot overwrite the new owner. Use assignee="captain" only when you will finish that task in this turn; a captain can own only one unfinished takeover at a time, and an unfinished takeover returns to the member pool when the captain becomes idle.',
|
|
459
1186
|
parameters: {
|
|
460
1187
|
task_id: { type: 'string', required: true, description: 'Task to retry/reassign.' },
|
|
461
1188
|
assignee: { type: 'string', required: true, description: 'Active member name, or "captain" for captain takeover.' },
|
|
@@ -495,7 +1222,17 @@ export function registerAgentTeamsTools(ctx, config) {
|
|
|
495
1222
|
if (task.reassigning === true)
|
|
496
1223
|
throw new Error(`task ${task.id} is already being reassigned`);
|
|
497
1224
|
const targetMember = target === CAPTAIN_KEY ? undefined : requireMember(fresh, target);
|
|
498
|
-
if (
|
|
1225
|
+
if (target === CAPTAIN_KEY) {
|
|
1226
|
+
const busy = captainOpenTask(fresh, task.id);
|
|
1227
|
+
if (busy !== undefined) {
|
|
1228
|
+
throw new Error(`captain is busy with ${busy.id}; complete or reassign it before taking over ${task.id}`);
|
|
1229
|
+
}
|
|
1230
|
+
const pending = unsatisfiedDependencies(fresh.tasks, task.dependencies);
|
|
1231
|
+
if (pending.length > 0) {
|
|
1232
|
+
throw new Error(`task ${task.id} is blocked by unfinished dependencies: ${pending.join(', ')} — complete them before captain takeover`);
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
else if (targetMember !== undefined) {
|
|
499
1236
|
const busy = memberOpenTask(fresh, targetMember.name, task.id);
|
|
500
1237
|
if (busy !== undefined) {
|
|
501
1238
|
throw new Error(`member "${targetMember.name}" is busy with ${busy.id}; finish or reassign it first`);
|
|
@@ -531,8 +1268,13 @@ export function registerAgentTeamsTools(ctx, config) {
|
|
|
531
1268
|
throw new Error(`task ${task.id} changed during reassignment; refusing to overwrite the newer state`);
|
|
532
1269
|
}
|
|
533
1270
|
task.reassigning = false;
|
|
534
|
-
if (quiescenceError === undefined && target === CAPTAIN_KEY)
|
|
1271
|
+
if (quiescenceError === undefined && target === CAPTAIN_KEY) {
|
|
535
1272
|
beginTaskAttempt(task, CAPTAIN_KEY);
|
|
1273
|
+
// The captain is already in the turn that requested takeover; there
|
|
1274
|
+
// is no later member claim handshake to move claimed -> in_progress.
|
|
1275
|
+
task.status = 'in_progress';
|
|
1276
|
+
task.updatedAt = Date.now();
|
|
1277
|
+
}
|
|
536
1278
|
await writeTeam(stateRoot, fresh);
|
|
537
1279
|
appendTeamEvent(ctx, captain.session, 'agent-teams/task-updated', {
|
|
538
1280
|
teamId: fresh.id,
|
|
@@ -669,6 +1411,60 @@ export function registerAgentTeamsTools(ctx, config) {
|
|
|
669
1411
|
},
|
|
670
1412
|
output: { type: 'string', description: 'Result summary; set when completing or failing.' },
|
|
671
1413
|
attempt_id: { type: 'string', description: 'Current execution capability returned by claim_task (required for members when present on the task).' },
|
|
1414
|
+
verdict: {
|
|
1415
|
+
type: 'string',
|
|
1416
|
+
enum: ['pass', 'needs_revision', 'reject'],
|
|
1417
|
+
description: 'Required for completing requirements/review. needs_revision and reject must fail the task.',
|
|
1418
|
+
},
|
|
1419
|
+
findings: {
|
|
1420
|
+
type: 'array',
|
|
1421
|
+
items: {
|
|
1422
|
+
type: 'object',
|
|
1423
|
+
additionalProperties: false,
|
|
1424
|
+
properties: {
|
|
1425
|
+
id: { type: 'string', required: true },
|
|
1426
|
+
severity: { type: 'string', enum: ['low', 'medium', 'high', 'blocker'], required: true },
|
|
1427
|
+
problem: { type: 'string', required: true },
|
|
1428
|
+
requiredFix: { type: 'string', required: true },
|
|
1429
|
+
file: { type: 'string' },
|
|
1430
|
+
line: { type: 'number' },
|
|
1431
|
+
resolved: { type: 'boolean' },
|
|
1432
|
+
},
|
|
1433
|
+
},
|
|
1434
|
+
description: 'Structured review findings. Required when verdict is needs_revision or reject; each item needs id, severity, problem, and requiredFix.',
|
|
1435
|
+
},
|
|
1436
|
+
changedPaths: {
|
|
1437
|
+
type: 'array',
|
|
1438
|
+
items: { type: 'string' },
|
|
1439
|
+
description: 'Workspace-relative POSIX paths changed by this implementation/repair.',
|
|
1440
|
+
},
|
|
1441
|
+
acceptanceResults: {
|
|
1442
|
+
type: 'array',
|
|
1443
|
+
items: {
|
|
1444
|
+
type: 'object',
|
|
1445
|
+
additionalProperties: false,
|
|
1446
|
+
properties: {
|
|
1447
|
+
criterion: { type: 'string', required: true },
|
|
1448
|
+
status: { type: 'string', enum: ['passed', 'failed'], required: true },
|
|
1449
|
+
evidence: { type: 'string' },
|
|
1450
|
+
},
|
|
1451
|
+
},
|
|
1452
|
+
description: 'Acceptance evidence in contract order: {criterion, status:"passed"|"failed", evidence?}. Supply one item per acceptance criterion.',
|
|
1453
|
+
},
|
|
1454
|
+
commandsRun: {
|
|
1455
|
+
type: 'array',
|
|
1456
|
+
items: {
|
|
1457
|
+
type: 'object',
|
|
1458
|
+
additionalProperties: false,
|
|
1459
|
+
properties: {
|
|
1460
|
+
command: { type: 'string', required: true },
|
|
1461
|
+
status: { type: 'string', enum: ['passed', 'failed'], required: true },
|
|
1462
|
+
exitCode: { type: 'number' },
|
|
1463
|
+
evidence: { type: 'string' },
|
|
1464
|
+
},
|
|
1465
|
+
},
|
|
1466
|
+
description: 'Verification evidence in contract order: {command, status:"passed"|"failed", exitCode?, evidence?}. Supply one item per verify command.',
|
|
1467
|
+
},
|
|
672
1468
|
},
|
|
673
1469
|
output: {
|
|
674
1470
|
schema: {
|
|
@@ -722,6 +1518,20 @@ export function registerAgentTeamsTools(ctx, config) {
|
|
|
722
1518
|
...task.output !== undefined ? { output: task.output } : {},
|
|
723
1519
|
};
|
|
724
1520
|
}
|
|
1521
|
+
const findings = parseFindings(args.findings);
|
|
1522
|
+
const acceptanceResults = parseAcceptanceResults(args.acceptanceResults);
|
|
1523
|
+
const commandsRun = parseCommandResults(args.commandsRun);
|
|
1524
|
+
const gate = evaluateQualityCompletion(task, {
|
|
1525
|
+
status: args.status,
|
|
1526
|
+
output: args.output,
|
|
1527
|
+
verdict: args.verdict,
|
|
1528
|
+
findings,
|
|
1529
|
+
changedPaths: args.changedPaths,
|
|
1530
|
+
acceptanceResults,
|
|
1531
|
+
commandsRun,
|
|
1532
|
+
});
|
|
1533
|
+
if (!gate.ok)
|
|
1534
|
+
throw new Error(gate.error ?? 'update_task rejected by quality gates');
|
|
725
1535
|
if (args.status !== undefined) {
|
|
726
1536
|
const transition = transitionError(task.status, args.status);
|
|
727
1537
|
if (transition !== undefined)
|
|
@@ -730,7 +1540,23 @@ export function registerAgentTeamsTools(ctx, config) {
|
|
|
730
1540
|
}
|
|
731
1541
|
if (args.output !== undefined)
|
|
732
1542
|
task.output = args.output;
|
|
1543
|
+
if (args.verdict !== undefined)
|
|
1544
|
+
task.verdict = args.verdict;
|
|
1545
|
+
if (findings !== undefined)
|
|
1546
|
+
task.findings = findings;
|
|
1547
|
+
if (args.changedPaths !== undefined)
|
|
1548
|
+
task.changedPaths = args.changedPaths;
|
|
1549
|
+
if (acceptanceResults !== undefined)
|
|
1550
|
+
task.acceptanceResults = acceptanceResults;
|
|
1551
|
+
if (commandsRun !== undefined)
|
|
1552
|
+
task.commandsRun = commandsRun;
|
|
733
1553
|
task.updatedAt = Date.now();
|
|
1554
|
+
const followUp = (task.status === 'failed' && (task.verdict === 'needs_revision' || task.verdict === 'reject'))
|
|
1555
|
+
? applyQualityFollowUp(fresh, task)
|
|
1556
|
+
: undefined;
|
|
1557
|
+
if (followUp?.escalated === true) {
|
|
1558
|
+
await appendMailbox(stateRoot, fresh.id, CAPTAIN_KEY, createMessage(CAPTAIN_KEY, CAPTAIN_KEY, `Quality-gate loop escalated after ${task.id} (${task.kind ?? 'review'} verdict=${task.verdict}). Automatic repair/review stopped.`));
|
|
1559
|
+
}
|
|
734
1560
|
await writeTeam(stateRoot, fresh);
|
|
735
1561
|
appendTeamEvent(ctx, captainSessionOf(ctx, fresh.captainSessionId, caller.session), 'agent-teams/task-updated', {
|
|
736
1562
|
teamId: fresh.id,
|
|
@@ -738,7 +1564,20 @@ export function registerAgentTeamsTools(ctx, config) {
|
|
|
738
1564
|
status: task.status,
|
|
739
1565
|
...task.assignee !== undefined ? { assignee: task.assignee } : {},
|
|
740
1566
|
...task.output !== undefined ? { output: task.output } : {},
|
|
1567
|
+
...task.verdict === undefined ? {} : { verdict: task.verdict },
|
|
1568
|
+
...task.round === undefined ? {} : { round: task.round },
|
|
741
1569
|
});
|
|
1570
|
+
for (const created of followUp?.created ?? []) {
|
|
1571
|
+
appendTeamEvent(ctx, captainSessionOf(ctx, fresh.captainSessionId, caller.session), 'agent-teams/task-created', {
|
|
1572
|
+
teamId: fresh.id,
|
|
1573
|
+
taskId: created.id,
|
|
1574
|
+
subject: created.subject,
|
|
1575
|
+
dependencies: created.dependencies,
|
|
1576
|
+
...created.assignee === undefined ? {} : { assignee: created.assignee },
|
|
1577
|
+
...created.kind === undefined ? {} : { kind: created.kind },
|
|
1578
|
+
...created.round === undefined ? {} : { round: created.round },
|
|
1579
|
+
});
|
|
1580
|
+
}
|
|
742
1581
|
return {
|
|
743
1582
|
task_id: task.id,
|
|
744
1583
|
status: task.status,
|
|
@@ -802,6 +1641,9 @@ export function registerAgentTeamsTools(ctx, config) {
|
|
|
802
1641
|
});
|
|
803
1642
|
return { kind: 'captain', fresh, identity, message, from };
|
|
804
1643
|
}
|
|
1644
|
+
if (fresh.halted === true) {
|
|
1645
|
+
throw new Error(`team "${fresh.name}" is halted; call agent_teams_resume before waking a member`);
|
|
1646
|
+
}
|
|
805
1647
|
const recipient = requireMember(fresh, to);
|
|
806
1648
|
const message = { ...createMessage(from, recipient.name, args.content), deliveryClaimedAt: Date.now() };
|
|
807
1649
|
await appendMailbox(stateRoot, fresh.id, recipient.name, message);
|
|
@@ -892,6 +1734,11 @@ export function registerAgentTeamsTools(ctx, config) {
|
|
|
892
1734
|
attempt: task.attempt ?? 0,
|
|
893
1735
|
attempt_id: task.attemptId ?? '',
|
|
894
1736
|
reassigning: task.reassigning === true,
|
|
1737
|
+
kind: taskKindOf(task),
|
|
1738
|
+
...task.round === undefined ? {} : { round: task.round },
|
|
1739
|
+
...task.verdict === undefined ? {} : { verdict: task.verdict },
|
|
1740
|
+
findings_open: (task.findings ?? []).filter((finding) => finding.resolved !== true).length,
|
|
1741
|
+
...task.profileSeedId === undefined ? {} : { seed_id: task.profileSeedId },
|
|
895
1742
|
...task.output !== undefined ? { output: task.output } : {},
|
|
896
1743
|
}));
|
|
897
1744
|
const mailboxWarnings = [];
|
|
@@ -918,10 +1765,36 @@ export function registerAgentTeamsTools(ctx, config) {
|
|
|
918
1765
|
};
|
|
919
1766
|
}
|
|
920
1767
|
}
|
|
1768
|
+
const coverage = buildCoverageMatrix([...new Set(team.tasks.flatMap((item) => item.coverageOf ?? []))], team.tasks).map((row) => ({
|
|
1769
|
+
goal_item: row.goal_item,
|
|
1770
|
+
task_ids: [...row.task_ids],
|
|
1771
|
+
status: row.status,
|
|
1772
|
+
...row.evidence === undefined ? {} : { evidence: row.evidence },
|
|
1773
|
+
}));
|
|
1774
|
+
const deliveryCheck = canDeclareDelivery(team);
|
|
1775
|
+
const delivery = { ok: deliveryCheck.ok, blockers: [...deliveryCheck.blockers] };
|
|
1776
|
+
const loop = describeQualityLoop(team);
|
|
921
1777
|
const result = {
|
|
922
1778
|
team_id: team.id,
|
|
923
1779
|
team_name: team.name,
|
|
924
1780
|
description: team.description ?? '',
|
|
1781
|
+
phase: team.phase ?? 'running',
|
|
1782
|
+
halted: loop.halted,
|
|
1783
|
+
escalated: loop.escalated,
|
|
1784
|
+
loop_state: loop.state,
|
|
1785
|
+
loop_summary: loop.summary,
|
|
1786
|
+
deliverable: loop.deliverable,
|
|
1787
|
+
coverage,
|
|
1788
|
+
delivery,
|
|
1789
|
+
...team.profile === undefined ? {} : {
|
|
1790
|
+
profile: {
|
|
1791
|
+
name: team.profile.name,
|
|
1792
|
+
...team.profile.protocol === undefined
|
|
1793
|
+
? {}
|
|
1794
|
+
: { protocol: team.profile.protocol.slice(0, 240) },
|
|
1795
|
+
...team.profile.taskPlanning === undefined ? {} : { task_planning: team.profile.taskPlanning },
|
|
1796
|
+
},
|
|
1797
|
+
},
|
|
925
1798
|
viewer: identity.name,
|
|
926
1799
|
members,
|
|
927
1800
|
tasks,
|
|
@@ -943,6 +1816,59 @@ export function registerAgentTeamsTools(ctx, config) {
|
|
|
943
1816
|
return result;
|
|
944
1817
|
},
|
|
945
1818
|
}));
|
|
1819
|
+
ctx.tools.register(defineTool({
|
|
1820
|
+
name: 'agent_teams_resume',
|
|
1821
|
+
description: 'Explicitly resume a halted team. Requires a non-empty reason. Does not recreate cancelled tasks; only still-pending work is scheduled.',
|
|
1822
|
+
parameters: {
|
|
1823
|
+
reason: { type: 'string', required: true, description: 'Why the team is being resumed.' },
|
|
1824
|
+
},
|
|
1825
|
+
output: {
|
|
1826
|
+
schema: {
|
|
1827
|
+
type: 'object',
|
|
1828
|
+
additionalProperties: false,
|
|
1829
|
+
properties: {
|
|
1830
|
+
status: { type: 'string', required: true },
|
|
1831
|
+
team_id: { type: 'string', required: true },
|
|
1832
|
+
reason: { type: 'string', required: true },
|
|
1833
|
+
},
|
|
1834
|
+
},
|
|
1835
|
+
render: (_args, value) => [{
|
|
1836
|
+
type: 'text',
|
|
1837
|
+
text: value.status === 'already_running'
|
|
1838
|
+
? `Team ${value.team_id} is already running.`
|
|
1839
|
+
: `Team ${value.team_id} resumed (${value.reason}).`,
|
|
1840
|
+
}],
|
|
1841
|
+
},
|
|
1842
|
+
async execute(args, exec) {
|
|
1843
|
+
const captain = requireCaptain(exec);
|
|
1844
|
+
const workspace = workspaceOf(captain);
|
|
1845
|
+
const stateRoot = stateRootOf(workspace, config);
|
|
1846
|
+
const team = await requireCaptainTeam(workspace, config, captain);
|
|
1847
|
+
const result = await withTeamLock(teamLockKey(stateRoot, team.id), async () => {
|
|
1848
|
+
const fresh = await requireFreshCaptainTeam(stateRoot, team.id, captain.id);
|
|
1849
|
+
const resumed = resumeTeamState(fresh, args.reason);
|
|
1850
|
+
if (resumed.status === 'rejected')
|
|
1851
|
+
throw new Error(resumed.error ?? 'resume rejected');
|
|
1852
|
+
if (resumed.status === 'resumed') {
|
|
1853
|
+
fresh.halted = false;
|
|
1854
|
+
fresh.haltedAt = undefined;
|
|
1855
|
+
await writeTeam(stateRoot, fresh);
|
|
1856
|
+
appendTeamEvent(ctx, captainSessionOf(ctx, fresh.captainSessionId, captain.session), 'agent-teams/team-resumed', {
|
|
1857
|
+
teamId: fresh.id,
|
|
1858
|
+
reason: args.reason,
|
|
1859
|
+
});
|
|
1860
|
+
}
|
|
1861
|
+
return {
|
|
1862
|
+
status: resumed.status,
|
|
1863
|
+
team_id: fresh.id,
|
|
1864
|
+
reason: args.reason,
|
|
1865
|
+
};
|
|
1866
|
+
});
|
|
1867
|
+
if (result.status === 'resumed')
|
|
1868
|
+
await scheduler.kickTeam(workspace, team.id, captain);
|
|
1869
|
+
return result;
|
|
1870
|
+
},
|
|
1871
|
+
}));
|
|
946
1872
|
ctx.tools.register(defineTool({
|
|
947
1873
|
name: 'agent_teams_delete',
|
|
948
1874
|
description: 'End your team: interrupts all members (best effort) and deletes the team\'s state directory (team file, tasks, mailboxes). Use when the team\'s work is done or abandoned.',
|
|
@@ -976,7 +1902,7 @@ export function registerAgentTeamsTools(ctx, config) {
|
|
|
976
1902
|
continue;
|
|
977
1903
|
member.status = 'removed';
|
|
978
1904
|
for (const task of fresh.tasks) {
|
|
979
|
-
if (task.assignee === member.name && task.status
|
|
1905
|
+
if (task.assignee === member.name && !TERMINAL_TASK_STATUSES.includes(task.status))
|
|
980
1906
|
invalidateTaskAttempt(task);
|
|
981
1907
|
}
|
|
982
1908
|
}
|
|
@@ -1007,19 +1933,274 @@ export function registerAgentTeamsTools(ctx, config) {
|
|
|
1007
1933
|
return { deleted: true, team_name: team.name };
|
|
1008
1934
|
},
|
|
1009
1935
|
}));
|
|
1936
|
+
return runtime;
|
|
1937
|
+
}
|
|
1938
|
+
async function initializeProfileTeam(input) {
|
|
1939
|
+
const profile = resolveTeamProfile(input.config.profiles, input.profileName, input.config.maxMembers);
|
|
1940
|
+
const selections = [];
|
|
1941
|
+
for (const template of profile.members) {
|
|
1942
|
+
selections.push(await resolveMemberLlmSelection(input.ctx, input.captain, {
|
|
1943
|
+
provider: template.provider,
|
|
1944
|
+
model: template.model,
|
|
1945
|
+
defaultModel: input.config.memberModel,
|
|
1946
|
+
reasoningEffort: template.reasoningEffort,
|
|
1947
|
+
fallback: template.fallback ?? profile.fallback ?? input.config.fallback,
|
|
1948
|
+
}, input.exec.signal));
|
|
1949
|
+
}
|
|
1950
|
+
await validateMemberLlmSelections(input.ctx, selections, input.exec.signal);
|
|
1951
|
+
const now = Date.now();
|
|
1952
|
+
const seedToActual = new Map(profile.tasks.map((template, index) => [template.id, `t${index + 1}`]));
|
|
1953
|
+
const draft = {
|
|
1954
|
+
name: input.teamName,
|
|
1955
|
+
id: input.teamId,
|
|
1956
|
+
description: input.description,
|
|
1957
|
+
profile: {
|
|
1958
|
+
name: profile.name,
|
|
1959
|
+
...profile.description === undefined ? {} : { description: profile.description },
|
|
1960
|
+
...profile.protocol === undefined ? {} : { protocol: profile.protocol },
|
|
1961
|
+
...profile.executionPrompt === undefined ? {} : { executionPrompt: profile.executionPrompt },
|
|
1962
|
+
...profile.fallback === undefined ? {} : { fallback: profile.fallback },
|
|
1963
|
+
taskPlanning: profile.taskPlanning,
|
|
1964
|
+
...profile.reviewPolicy === undefined ? {} : { reviewPolicy: profile.reviewPolicy },
|
|
1965
|
+
},
|
|
1966
|
+
...profile.reviewPolicy === undefined ? {} : { reviewPolicy: profile.reviewPolicy },
|
|
1967
|
+
captainSessionId: input.captain.id,
|
|
1968
|
+
createdAt: now,
|
|
1969
|
+
...input.staged ? { phase: 'staged', planReviewState: 'awaiting_review' } : {},
|
|
1970
|
+
members: profile.members.map((template, index) => {
|
|
1971
|
+
const selection = selections[index];
|
|
1972
|
+
return {
|
|
1973
|
+
id: '',
|
|
1974
|
+
name: template.name,
|
|
1975
|
+
role: template.role,
|
|
1976
|
+
provider: selection.provider,
|
|
1977
|
+
model: selection.model,
|
|
1978
|
+
reasoningEffort: selection.reasoningEffort,
|
|
1979
|
+
executionPrompt: template.executionPrompt ?? profile.executionPrompt ?? input.config.executionPrompt,
|
|
1980
|
+
...selection.fallback === undefined ? {} : { fallback: selection.fallback },
|
|
1981
|
+
joinedAt: now,
|
|
1982
|
+
status: 'idle',
|
|
1983
|
+
};
|
|
1984
|
+
}),
|
|
1985
|
+
tasks: profile.tasks.map((template, index) => ({
|
|
1986
|
+
id: `t${index + 1}`,
|
|
1987
|
+
profileSeedId: template.id,
|
|
1988
|
+
subject: template.subject,
|
|
1989
|
+
description: template.description,
|
|
1990
|
+
status: 'pending',
|
|
1991
|
+
assignee: template.assignee,
|
|
1992
|
+
dependencies: template.dependencies.map((dependency) => seedToActual.get(dependency) ?? dependency),
|
|
1993
|
+
attempt: 0,
|
|
1994
|
+
createdAt: now,
|
|
1995
|
+
updatedAt: now,
|
|
1996
|
+
})),
|
|
1997
|
+
taskSeq: profile.tasks.length,
|
|
1998
|
+
};
|
|
1999
|
+
if (input.staged) {
|
|
2000
|
+
await createTeamDir(input.stateRoot, draft);
|
|
2001
|
+
return { committed: true, state: draft };
|
|
2002
|
+
}
|
|
2003
|
+
const spawned = [];
|
|
2004
|
+
try {
|
|
2005
|
+
for (const member of draft.members) {
|
|
2006
|
+
const selection = selections[spawned.length];
|
|
2007
|
+
await spawnMember(input.ctx, memberRuntime(input.config), input.memberSelections, selection, input.captain, draft, member, input.config.stateDir, input.exec.signal);
|
|
2008
|
+
spawned.push(member);
|
|
2009
|
+
}
|
|
2010
|
+
if (draft.members.some((member) => member.id === '')) {
|
|
2011
|
+
throw new Error(`failed to initialize profile "${profile.name}": a spawned member is missing its child id`);
|
|
2012
|
+
}
|
|
2013
|
+
await createTeamDir(input.stateRoot, draft);
|
|
2014
|
+
return { committed: true, state: draft };
|
|
2015
|
+
}
|
|
2016
|
+
catch (error) {
|
|
2017
|
+
const cleanupErrors = [];
|
|
2018
|
+
try {
|
|
2019
|
+
await removeTeamDir(input.stateRoot, draft.id);
|
|
2020
|
+
}
|
|
2021
|
+
catch (cleanupError) {
|
|
2022
|
+
cleanupErrors.push(cleanupError);
|
|
2023
|
+
}
|
|
2024
|
+
try {
|
|
2025
|
+
await recordRetiredMemberIds(input.stateRoot, spawned.map((member) => member.id));
|
|
2026
|
+
}
|
|
2027
|
+
catch (cleanupError) {
|
|
2028
|
+
cleanupErrors.push(cleanupError);
|
|
2029
|
+
}
|
|
2030
|
+
for (const member of spawned) {
|
|
2031
|
+
try {
|
|
2032
|
+
interruptMember(input.ctx, input.captain, member.id);
|
|
2033
|
+
}
|
|
2034
|
+
catch (cleanupError) {
|
|
2035
|
+
cleanupErrors.push(cleanupError);
|
|
2036
|
+
}
|
|
2037
|
+
}
|
|
2038
|
+
if (cleanupErrors.length > 0) {
|
|
2039
|
+
throw new AggregateError([error, ...cleanupErrors], `failed to initialize profile "${profile.name}"`);
|
|
2040
|
+
}
|
|
2041
|
+
throw error;
|
|
2042
|
+
}
|
|
2043
|
+
}
|
|
2044
|
+
function parseFindings(value) {
|
|
2045
|
+
if (value === undefined)
|
|
2046
|
+
return undefined;
|
|
2047
|
+
if (!Array.isArray(value))
|
|
2048
|
+
throw new Error('findings must be an array');
|
|
2049
|
+
return value.map((item, index) => {
|
|
2050
|
+
if (typeof item !== 'object' || item === null || Array.isArray(item)) {
|
|
2051
|
+
throw new Error(`findings[${index}] must be an object`);
|
|
2052
|
+
}
|
|
2053
|
+
const raw = item;
|
|
2054
|
+
if (typeof raw['id'] !== 'string' || raw['id'].trim() === '')
|
|
2055
|
+
throw new Error(`findings[${index}].id is required`);
|
|
2056
|
+
if (raw['severity'] !== 'low' && raw['severity'] !== 'medium' && raw['severity'] !== 'high' && raw['severity'] !== 'blocker') {
|
|
2057
|
+
throw new Error(`findings[${index}].severity is invalid`);
|
|
2058
|
+
}
|
|
2059
|
+
if (typeof raw['problem'] !== 'string' || raw['problem'].trim() === '')
|
|
2060
|
+
throw new Error(`findings[${index}].problem is required`);
|
|
2061
|
+
if (typeof raw['requiredFix'] !== 'string' || raw['requiredFix'].trim() === '')
|
|
2062
|
+
throw new Error(`findings[${index}].requiredFix is required`);
|
|
2063
|
+
return {
|
|
2064
|
+
id: raw['id'].trim(),
|
|
2065
|
+
severity: raw['severity'],
|
|
2066
|
+
problem: raw['problem'],
|
|
2067
|
+
requiredFix: raw['requiredFix'],
|
|
2068
|
+
...typeof raw['file'] === 'string' ? { file: raw['file'] } : {},
|
|
2069
|
+
...typeof raw['line'] === 'number' ? { line: raw['line'] } : {},
|
|
2070
|
+
...typeof raw['resolved'] === 'boolean' ? { resolved: raw['resolved'] } : {},
|
|
2071
|
+
};
|
|
2072
|
+
});
|
|
2073
|
+
}
|
|
2074
|
+
function parseAcceptanceResults(value) {
|
|
2075
|
+
if (value === undefined)
|
|
2076
|
+
return undefined;
|
|
2077
|
+
if (!Array.isArray(value))
|
|
2078
|
+
throw new Error('acceptanceResults must be an array');
|
|
2079
|
+
return value.map((item, index) => {
|
|
2080
|
+
if (typeof item !== 'object' || item === null || Array.isArray(item)) {
|
|
2081
|
+
throw new Error(`acceptanceResults[${index}] must be an object`);
|
|
2082
|
+
}
|
|
2083
|
+
const raw = item;
|
|
2084
|
+
if (typeof raw['criterion'] !== 'string' || raw['criterion'].trim() === '') {
|
|
2085
|
+
throw new Error(`acceptanceResults[${index}].criterion is required`);
|
|
2086
|
+
}
|
|
2087
|
+
if (raw['status'] !== 'passed' && raw['status'] !== 'failed') {
|
|
2088
|
+
throw new Error(`acceptanceResults[${index}].status must be passed or failed`);
|
|
2089
|
+
}
|
|
2090
|
+
return {
|
|
2091
|
+
criterion: raw['criterion'],
|
|
2092
|
+
status: raw['status'],
|
|
2093
|
+
...typeof raw['evidence'] === 'string' ? { evidence: raw['evidence'] } : {},
|
|
2094
|
+
};
|
|
2095
|
+
});
|
|
2096
|
+
}
|
|
2097
|
+
function parseCommandResults(value) {
|
|
2098
|
+
if (value === undefined)
|
|
2099
|
+
return undefined;
|
|
2100
|
+
if (!Array.isArray(value))
|
|
2101
|
+
throw new Error('commandsRun must be an array');
|
|
2102
|
+
return value.map((item, index) => {
|
|
2103
|
+
if (typeof item !== 'object' || item === null || Array.isArray(item)) {
|
|
2104
|
+
throw new Error(`commandsRun[${index}] must be an object`);
|
|
2105
|
+
}
|
|
2106
|
+
const raw = item;
|
|
2107
|
+
if (typeof raw['command'] !== 'string' || raw['command'].trim() === '') {
|
|
2108
|
+
throw new Error(`commandsRun[${index}].command is required`);
|
|
2109
|
+
}
|
|
2110
|
+
if (raw['status'] !== 'passed' && raw['status'] !== 'failed') {
|
|
2111
|
+
throw new Error(`commandsRun[${index}].status must be passed or failed`);
|
|
2112
|
+
}
|
|
2113
|
+
return {
|
|
2114
|
+
command: raw['command'],
|
|
2115
|
+
status: raw['status'],
|
|
2116
|
+
...typeof raw['exitCode'] === 'number' ? { exitCode: raw['exitCode'] } : {},
|
|
2117
|
+
...typeof raw['evidence'] === 'string' ? { evidence: raw['evidence'] } : {},
|
|
2118
|
+
};
|
|
2119
|
+
});
|
|
2120
|
+
}
|
|
2121
|
+
export function applyQualityFollowUp(team, closed) {
|
|
2122
|
+
const planned = planQualityFollowUp(team, closed);
|
|
2123
|
+
if (planned.escalated === true)
|
|
2124
|
+
team.escalated = true;
|
|
2125
|
+
const created = [];
|
|
2126
|
+
const existing = [...team.tasks];
|
|
2127
|
+
const now = Date.now();
|
|
2128
|
+
const idBySubject = new Map();
|
|
2129
|
+
for (const draft of planned.created) {
|
|
2130
|
+
team.taskSeq += 1;
|
|
2131
|
+
const id = `t${team.taskSeq}`;
|
|
2132
|
+
if (draft.id !== undefined)
|
|
2133
|
+
idBySubject.set(draft.id, id);
|
|
2134
|
+
if (draft.subject !== undefined)
|
|
2135
|
+
idBySubject.set(draft.subject, id);
|
|
2136
|
+
const dependencies = (draft.dependencies ?? []).map((dependency) => {
|
|
2137
|
+
if (team.tasks.some((item) => item.id === dependency))
|
|
2138
|
+
return dependency;
|
|
2139
|
+
return idBySubject.get(dependency) ?? dependency;
|
|
2140
|
+
});
|
|
2141
|
+
const next = {
|
|
2142
|
+
id,
|
|
2143
|
+
subject: draft.subject ?? `${draft.kind}-round-${draft.round ?? 1}`,
|
|
2144
|
+
status: 'pending',
|
|
2145
|
+
assignee: draft.assignee,
|
|
2146
|
+
dependencies,
|
|
2147
|
+
attempt: 0,
|
|
2148
|
+
createdAt: now,
|
|
2149
|
+
updatedAt: now,
|
|
2150
|
+
kind: draft.kind,
|
|
2151
|
+
...draft.round === undefined ? {} : { round: draft.round },
|
|
2152
|
+
...draft.objective === undefined ? {} : { objective: draft.objective },
|
|
2153
|
+
...draft.inScope === undefined ? {} : { inScope: draft.inScope },
|
|
2154
|
+
...draft.outOfScope === undefined ? {} : { outOfScope: draft.outOfScope },
|
|
2155
|
+
...draft.acceptance === undefined ? {} : { acceptance: draft.acceptance },
|
|
2156
|
+
...draft.verify === undefined ? {} : { verify: draft.verify },
|
|
2157
|
+
...draft.sourceTaskId === undefined ? {} : { sourceTaskId: draft.sourceTaskId },
|
|
2158
|
+
...draft.sourceFindingIds === undefined ? {} : { sourceFindingIds: draft.sourceFindingIds },
|
|
2159
|
+
...draft.reviewedTaskId === undefined ? {} : { reviewedTaskId: idBySubject.get(draft.reviewedTaskId) ?? draft.reviewedTaskId },
|
|
2160
|
+
};
|
|
2161
|
+
team.tasks.push(next);
|
|
2162
|
+
created.push(next);
|
|
2163
|
+
}
|
|
2164
|
+
// A staged full delivery plan may already contain downstream integration
|
|
2165
|
+
// work that points at the first requirements/review gate. When that gate
|
|
2166
|
+
// opens an automatic revision loop, move only still-pending downstream
|
|
2167
|
+
// edges to the new terminal gate so the approved plan can continue after
|
|
2168
|
+
// the repair instead of waiting forever on an intentionally failed task.
|
|
2169
|
+
const replacement = created.at(-1);
|
|
2170
|
+
if (replacement !== undefined) {
|
|
2171
|
+
for (const task of existing) {
|
|
2172
|
+
if (task.status !== 'pending' || !task.dependencies.includes(closed.id))
|
|
2173
|
+
continue;
|
|
2174
|
+
task.dependencies = task.dependencies.map((dependency) => (dependency === closed.id ? replacement.id : dependency));
|
|
2175
|
+
task.updatedAt = now;
|
|
2176
|
+
}
|
|
2177
|
+
}
|
|
2178
|
+
return { created, escalated: planned.escalated === true };
|
|
1010
2179
|
}
|
|
1011
2180
|
/** Build the `memberRuntime` config handed to member helpers. */
|
|
1012
2181
|
function memberRuntime(config) {
|
|
1013
2182
|
return {
|
|
1014
2183
|
provider: config.memberProvider,
|
|
1015
2184
|
maxDepth: config.memberMaxDepth,
|
|
2185
|
+
executionPrompt: config.executionPrompt,
|
|
2186
|
+
fallback: config.fallback,
|
|
1016
2187
|
};
|
|
1017
2188
|
}
|
|
1018
2189
|
/** Render the status snapshot as compact text for the model. */
|
|
1019
2190
|
function renderStatus(value) {
|
|
1020
2191
|
const team = value;
|
|
2192
|
+
const flags = [
|
|
2193
|
+
team.halted ? 'halted' : undefined,
|
|
2194
|
+
team.escalated ? 'escalated' : undefined,
|
|
2195
|
+
team.deliverable ? 'deliverable' : undefined,
|
|
2196
|
+
team.loop_state && team.loop_state !== 'running' && team.loop_state !== 'halted' && team.loop_state !== 'escalated'
|
|
2197
|
+
? team.loop_state
|
|
2198
|
+
: undefined,
|
|
2199
|
+
].filter((item) => item !== undefined);
|
|
1021
2200
|
const lines = [
|
|
1022
|
-
`Team "${team.team_name}"${team.description ? ` — ${team.description}` : ''}`,
|
|
2201
|
+
`Team "${team.team_name}"${team.description ? ` — ${team.description}` : ''}${flags.length > 0 ? ` [${flags.join(', ')}]` : ''}`,
|
|
2202
|
+
...team.profile === undefined ? [] : [`Profile: ${team.profile.name}${team.profile.task_planning ? ` [${team.profile.task_planning}]` : ''}${team.profile.protocol ? ` — ${team.profile.protocol}` : ''}`],
|
|
2203
|
+
...team.loop_summary ? [`Loop: ${team.loop_state ?? ''} — ${team.loop_summary}`.replace(/^Loop: — /u, 'Loop: ')] : [],
|
|
1023
2204
|
`Viewing as: ${team.viewer}`,
|
|
1024
2205
|
`Members (${team.members.length}):`,
|
|
1025
2206
|
...team.members.map((member) => {
|
|
@@ -1032,8 +2213,19 @@ function renderStatus(value) {
|
|
|
1032
2213
|
const deps = task.dependencies.length > 0 ? ` (deps: ${task.dependencies.join(',')})` : '';
|
|
1033
2214
|
const output = task.output !== undefined ? `\n output: ${task.output.slice(0, 300)}` : '';
|
|
1034
2215
|
const handoff = task.reassigning ? ' (reassigning)' : '';
|
|
1035
|
-
|
|
2216
|
+
const seed = task.seed_id === undefined || task.seed_id === '' ? '' : ` seed ${task.seed_id}`;
|
|
2217
|
+
const kind = task.kind ? ` ${task.kind}` : '';
|
|
2218
|
+
const round = task.round === undefined ? '' : ` r${task.round}`;
|
|
2219
|
+
const verdict = task.verdict === undefined ? '' : ` verdict ${task.verdict}`;
|
|
2220
|
+
return ` - ${task.id} [${task.status}]${kind}${round}${verdict} attempt ${task.attempt}${handoff}${seed} ${task.subject} → ${task.assignee || 'unassigned'}${deps}${output}`;
|
|
1036
2221
|
}),
|
|
2222
|
+
...team.coverage === undefined || team.coverage.length === 0 ? [] : [
|
|
2223
|
+
'Coverage:',
|
|
2224
|
+
...team.coverage.map((row) => ` - ${row.goal_item}: ${row.status} (${row.task_ids.join(',') || 'none'})`),
|
|
2225
|
+
],
|
|
2226
|
+
...team.delivery === undefined ? [] : [
|
|
2227
|
+
`Delivery: ${team.delivery.ok ? 'ok' : `blocked (${team.delivery.blockers.join('; ')})`}`,
|
|
2228
|
+
],
|
|
1037
2229
|
`Captain inbox (${team.captain_inbox.length}):`,
|
|
1038
2230
|
...team.captain_inbox.map((message) => ` - [${message.from}] ${message.content.slice(0, 200)}`),
|
|
1039
2231
|
];
|