@nanmicoder/dsh-agent-teams 0.1.17 → 0.1.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -5
- package/README_ZH.md +8 -4
- package/lib/client/AgentTeamsCard.js +1 -1
- package/lib/client.js +1 -1
- package/lib/client.js.map +1 -1
- package/lib/harness-compat.js +106 -0
- package/lib/index.js +4 -4
- package/lib/mailbox.js +154 -0
- package/lib/members.js +97 -23
- package/lib/quality-gates.js +181 -10
- package/lib/scheduler.js +27 -15
- package/lib/snapshot.js +1 -1
- package/lib/state.js +26 -6
- package/lib/tool-names.js +1 -0
- package/lib/tools.js +312 -214
- package/lib/types/event-types.d.ts +15 -1
- package/lib/types/harness-compat.d.ts +46 -0
- package/lib/types/index.d.ts +1 -1
- package/lib/types/mailbox.d.ts +7 -0
- package/lib/types/members.d.ts +6 -4
- package/lib/types/quality-gates.d.ts +51 -1
- package/lib/types/scheduler.d.ts +1 -0
- package/lib/types/state.d.ts +7 -1
- package/lib/types/tool-names.d.ts +2 -2
- package/lib/types/types.d.ts +30 -0
- package/package.json +6 -4
- package/release-notes/v0.1.18.md +21 -0
- package/release-notes/v0.1.19.md +20 -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, cancelUnfinishedTask, invalidateTaskAttempt, readUnreadMailbox, recordRetiredMemberIds, releaseMailboxDelivery, readTeam, sanitizeKey, transitionError, unsatisfiedDependencies, withTeamLock, writeTeam,
|
|
16
|
-
import { deliverToMember, installRetiredMemberGuard, installMemberSelectionRuntime,
|
|
15
|
+
import { amendTaskContract, acknowledgeMailbox, markMailboxDelivered, discardMailboxMessages, appendMailbox, archiveTeamDir, beginTaskAttempt, CAPTAIN_KEY, createMessage, createTeamDir, findTeamByCaptain, findTeamByParticipant, cancelUnfinishedTask, invalidateTaskAttempt, readUnreadMailbox, recordRetiredMemberIds, releaseMailboxDelivery, readTeam, sanitizeKey, transitionError, unsatisfiedDependencies, withTeamLock, writeTeam, validateCreateTask, evaluateQualityCompletion, planQualityFollowUp, resumeTeamState, buildCoverageMatrix, canDeclareDelivery, describeQualityLoop, sanitizeReviewAcceptance, sanitizeReviewObjective, normalizeBlankOptionalTaskFields, taskKindOf, } from "./state.js";
|
|
16
|
+
import { deliverToMember, installRetiredMemberGuard, installMemberSelectionRuntime, installMemberDelegationGuard, memberActivity, resolveMemberLlmSelection, spawnMember, steerCaptainReport, validateMemberLlmSelections, } from "./members.js";
|
|
17
17
|
import { TERMINAL_TASK_STATUSES } from "./types.js";
|
|
18
|
-
import { installTeamScheduler } from "./scheduler.js";
|
|
18
|
+
import { collectCompletedDependencyOutputs, formatDependencyOutputs, installTeamScheduler } from "./scheduler.js";
|
|
19
|
+
import { installMailboxAdmission, mailboxPrompt } from "./mailbox.js";
|
|
19
20
|
import { resolveTeamProfile } from "./profiles.js";
|
|
20
21
|
export { steerCaptainReport } from "./members.js";
|
|
21
22
|
/** The caller agent, or a loud failure for non-agent callers. */
|
|
@@ -158,32 +159,22 @@ function memberOpenTask(team, memberName, exceptTaskId) {
|
|
|
158
159
|
&& task.assignee === memberName
|
|
159
160
|
&& (task.status === 'claimed' || task.status === 'in_progress'));
|
|
160
161
|
}
|
|
162
|
+
function taskDetails(team, task) {
|
|
163
|
+
return [task.subject, task.description ?? '',
|
|
164
|
+
`Kind: ${task.kind ?? 'work'}`,
|
|
165
|
+
`Objective: ${task.objective ?? ''}`,
|
|
166
|
+
`In scope: ${(task.inScope ?? []).join(', ')}; Out of scope: ${(task.outOfScope ?? []).join(', ')}`,
|
|
167
|
+
`Acceptance: ${(task.acceptance ?? []).join('; ')}`,
|
|
168
|
+
`Verify: ${(task.verify ?? []).join('; ')}`,
|
|
169
|
+
`Dependency results:\n${formatDependencyOutputs(collectCompletedDependencyOutputs(team.tasks, task.id))}`,
|
|
170
|
+
].join('\n');
|
|
171
|
+
}
|
|
161
172
|
/** Captain work is immediate, not a durable scheduler lane: allow one unfinished takeover at a time. */
|
|
162
173
|
function captainOpenTask(team, exceptTaskId) {
|
|
163
174
|
return team.tasks.find(task => task.id !== exceptTaskId
|
|
164
175
|
&& task.assignee === CAPTAIN_KEY
|
|
165
176
|
&& !TERMINAL_TASK_STATUSES.includes(task.status));
|
|
166
177
|
}
|
|
167
|
-
async function waitForMemberIdle(ctx, member, signal) {
|
|
168
|
-
if (member.id === '')
|
|
169
|
-
return;
|
|
170
|
-
const live = ctx.agents.get(member.id);
|
|
171
|
-
if (live === undefined)
|
|
172
|
-
return;
|
|
173
|
-
if (signal.aborted)
|
|
174
|
-
throw signal.reason;
|
|
175
|
-
let onAbort;
|
|
176
|
-
const aborted = new Promise((_resolve, reject) => {
|
|
177
|
-
onAbort = () => reject(signal.reason ?? new Error('task reassignment was cancelled'));
|
|
178
|
-
signal.addEventListener('abort', onAbort, { once: true });
|
|
179
|
-
});
|
|
180
|
-
try {
|
|
181
|
-
await Promise.race([live.whenIdle(), aborted]);
|
|
182
|
-
}
|
|
183
|
-
finally {
|
|
184
|
-
signal.removeEventListener('abort', onAbort);
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
178
|
/** Stop every currently-resident member activation for one halted team.
|
|
188
179
|
*
|
|
189
180
|
* Interrupt requests only cancel the member's current model turn and retain its
|
|
@@ -192,34 +183,13 @@ async function waitForMemberIdle(ctx, member, signal) {
|
|
|
192
183
|
* keep executing after the captain-chat Stop control has reported success.
|
|
193
184
|
*/
|
|
194
185
|
async function stopTeamMemberActivations(ctx, captain, members, signal) {
|
|
195
|
-
const
|
|
196
|
-
const memberIds = activeMembers.map((member) => member.id);
|
|
186
|
+
const memberIds = members.filter(member => member.id !== '').map(member => member.id);
|
|
197
187
|
if (memberIds.length === 0)
|
|
198
188
|
return;
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
// hosts, where interrupt is the strongest available lifecycle operation.
|
|
204
|
-
const runtime = ctx.subagents;
|
|
205
|
-
if (runtime.drainContinuableChildren !== undefined) {
|
|
206
|
-
try {
|
|
207
|
-
await runtime.drainContinuableChildren(captain, memberIds);
|
|
208
|
-
return;
|
|
209
|
-
}
|
|
210
|
-
catch (error) {
|
|
211
|
-
// Do not claim the browser action stopped work when the runtime could not
|
|
212
|
-
// release all selected child activations. The HTTP route surfaces this
|
|
213
|
-
// failure instead of returning a false successful stop.
|
|
214
|
-
ctx.logger.warn(`agent-teams: failed to drain halted members: ${String(error)}`);
|
|
215
|
-
throw error;
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
const fallbackSignal = signal ?? new AbortController().signal;
|
|
219
|
-
const results = await Promise.allSettled(activeMembers.map((member) => waitForMemberIdle(ctx, member, fallbackSignal)));
|
|
220
|
-
const failed = results.find((result) => result.status === 'rejected');
|
|
221
|
-
if (failed?.status === 'rejected')
|
|
222
|
-
throw failed.reason;
|
|
189
|
+
// Every supported exact host has targeted recursive drain. Unlike interrupt,
|
|
190
|
+
// it closes admission and clears queued work before awaiting descendants.
|
|
191
|
+
signal?.throwIfAborted();
|
|
192
|
+
await ctx.subagents.drainContinuableChildren(captain, memberIds);
|
|
223
193
|
}
|
|
224
194
|
export async function haltTeamWork(input) {
|
|
225
195
|
const halted = await withTeamLock(teamLockKey(input.stateRoot, input.teamId), async () => {
|
|
@@ -310,16 +280,83 @@ export function stagedPlanFeedbackContext(teamName) {
|
|
|
310
280
|
*/
|
|
311
281
|
export function registerAgentTeamsTools(ctx, config) {
|
|
312
282
|
installRetiredMemberGuard(ctx, config.stateDir);
|
|
313
|
-
|
|
283
|
+
installMemberDelegationGuard(ctx, config.stateDir, config.memberMaxDepth ?? 0);
|
|
284
|
+
installMailboxAdmission(ctx, config.stateDir);
|
|
285
|
+
const scheduler = installTeamScheduler(ctx, { stateDir: config.stateDir, executionPrompt: config.executionPrompt, dispatch: dispatchMember });
|
|
314
286
|
const memberSelections = installMemberSelectionRuntime(ctx, config.stateDir, (workspace, teamId, memberName) => (scheduler.kickMember(workspace, teamId, memberName)));
|
|
315
|
-
|
|
287
|
+
async function dispatchMember(captain, teamId, memberName, text, signal, mode, attemptId) {
|
|
288
|
+
const root = stateRootOf(workspaceOf(captain), config);
|
|
289
|
+
// Record why a member never started. The scheduler treats a failed dispatch as
|
|
290
|
+
// "not now" and retries, so without this the captain only ever sees an
|
|
291
|
+
// unexplained `unspawned` member and no diagnostic reaches any surface.
|
|
292
|
+
const recordSpawnError = async (reason) => {
|
|
293
|
+
try {
|
|
294
|
+
await withTeamLock(teamLockKey(root, teamId), async () => {
|
|
295
|
+
const fresh = await requireFreshCaptainTeam(root, teamId, captain.id);
|
|
296
|
+
const failed = fresh.members.find(item => item.name === memberName && item.status !== 'removed');
|
|
297
|
+
if (failed === undefined || failed.id !== '')
|
|
298
|
+
return;
|
|
299
|
+
failed.spawnError = reason;
|
|
300
|
+
await writeTeam(root, fresh);
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
catch (error) {
|
|
304
|
+
ctx.logger.warn(`agent-teams: could not record the member start failure for ${memberName}: ${String(error)}`);
|
|
305
|
+
}
|
|
306
|
+
};
|
|
307
|
+
let orphan;
|
|
308
|
+
try {
|
|
309
|
+
return await withTeamLock(teamLockKey(root, teamId), async () => {
|
|
310
|
+
const team = await readTeam(root, teamId);
|
|
311
|
+
if (team?.captainSessionId !== captain.id || team.halted === true || team.phase === 'staged')
|
|
312
|
+
return false;
|
|
313
|
+
const member = team.members.find(item => item.name === memberName && item.status !== 'removed');
|
|
314
|
+
if (member === undefined || member.stopping === true || team.tasks.some(task => task.reassigning === true && task.assignee === memberName))
|
|
315
|
+
return false;
|
|
316
|
+
if (attemptId !== undefined && !team.tasks.some(task => task.attemptId === attemptId && task.assignee === memberName && (task.status === 'claimed' || task.status === 'in_progress')))
|
|
317
|
+
return false;
|
|
318
|
+
if (member.id !== '')
|
|
319
|
+
return deliverToMember(ctx, captain, member.id, text, signal, mode);
|
|
320
|
+
const selection = await resolveMemberLlmSelection(ctx, captain, {
|
|
321
|
+
provider: member.provider, model: member.model, reasoningEffort: member.reasoningEffort, fallback: member.fallback,
|
|
322
|
+
}, signal);
|
|
323
|
+
await spawnMember(ctx, memberRuntime(config), memberSelections, selection, captain, team, member, config.stateDir, signal, text);
|
|
324
|
+
orphan = { ...member };
|
|
325
|
+
delete member.spawnError;
|
|
326
|
+
await writeTeam(root, team);
|
|
327
|
+
orphan = undefined;
|
|
328
|
+
return true;
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
catch (error) {
|
|
332
|
+
if (orphan !== undefined) {
|
|
333
|
+
await recordRetiredMemberIds(root, [orphan.id]);
|
|
334
|
+
await stopTeamMemberActivations(ctx, captain, [orphan]);
|
|
335
|
+
}
|
|
336
|
+
// The stack carries the failing frame; the message alone rarely does.
|
|
337
|
+
let reason = String(error);
|
|
338
|
+
if (error instanceof Error && typeof error.stack === 'string' && error.stack !== '')
|
|
339
|
+
reason = error.stack;
|
|
340
|
+
ctx.logger.warn(`agent-teams: member dispatch failed for ${memberName}: ${String(error)}`);
|
|
341
|
+
await recordSpawnError(reason);
|
|
342
|
+
return false;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
const updatePlanBatch = async (captain, teamId, mutations, signal, allowPendingEdits = false) => {
|
|
316
346
|
if (mutations.length === 0)
|
|
317
347
|
throw new Error('at least one staged plan operation is required');
|
|
318
348
|
const workspace = workspaceOf(captain);
|
|
319
349
|
const stateRoot = stateRootOf(workspace, config);
|
|
320
350
|
return withTeamLock(teamLockKey(stateRoot, teamId), async () => {
|
|
321
351
|
const fresh = await requireFreshCaptainTeam(stateRoot, teamId, captain.id);
|
|
322
|
-
|
|
352
|
+
const staged = fresh.phase === 'staged';
|
|
353
|
+
if (!staged && !allowPendingEdits)
|
|
354
|
+
requireStagedTeam(fresh);
|
|
355
|
+
if (fresh.halted === true)
|
|
356
|
+
throw new Error('team is halted; resume before editing tasks');
|
|
357
|
+
if (!staged && mutations.some(mutation => mutation.action !== 'update_task')) {
|
|
358
|
+
throw new Error('a running team only permits update_task edits to pending, never-started tasks; roster and removal edits require a staged plan');
|
|
359
|
+
}
|
|
323
360
|
for (const mutation of mutations) {
|
|
324
361
|
if (mutation.action === 'update_member') {
|
|
325
362
|
const member = requireMember(fresh, mutation.memberName);
|
|
@@ -339,9 +376,11 @@ export function registerAgentTeamsTools(ctx, config) {
|
|
|
339
376
|
}
|
|
340
377
|
else if (mutation.action === 'update_task') {
|
|
341
378
|
const task = requireTask(fresh, mutation.taskId);
|
|
342
|
-
if (task.status !== 'pending' || (task.attempt ?? 0) !== 0) {
|
|
379
|
+
if (task.status !== 'pending' || (task.attempt ?? 0) !== 0 || task.reassigning === true) {
|
|
343
380
|
throw new Error(`task "${task.id}" has already started and cannot be edited`);
|
|
344
381
|
}
|
|
382
|
+
if (!staged && mutation.assignee === CAPTAIN_KEY)
|
|
383
|
+
throw new Error('use reassign_task for captain takeover');
|
|
345
384
|
const subject = mutation.subject.trim();
|
|
346
385
|
if (subject === '')
|
|
347
386
|
throw new Error('task subject must not be empty');
|
|
@@ -390,11 +429,25 @@ export function registerAgentTeamsTools(ctx, config) {
|
|
|
390
429
|
}
|
|
391
430
|
}
|
|
392
431
|
validateStagedGraph(fresh, false);
|
|
393
|
-
|
|
432
|
+
if (!staged) {
|
|
433
|
+
for (const mutation of mutations) {
|
|
434
|
+
if (mutation.action !== 'update_task')
|
|
435
|
+
continue;
|
|
436
|
+
const task = requireTask(fresh, mutation.taskId);
|
|
437
|
+
const validation = validateCreateTask({ ...fresh, tasks: fresh.tasks.filter(item => item.id !== task.id) }, task);
|
|
438
|
+
if (!validation.ok)
|
|
439
|
+
throw new Error(validation.error ?? 'edited task violates the quality contract');
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
else
|
|
443
|
+
fresh.planReviewState = 'awaiting_review';
|
|
444
|
+
signal?.throwIfAborted();
|
|
394
445
|
await writeTeam(stateRoot, fresh);
|
|
395
446
|
return fresh;
|
|
396
447
|
});
|
|
397
448
|
};
|
|
449
|
+
// Browser review controls retain their staged-only contract.
|
|
450
|
+
const updateStagedPlanBatch = (captain, teamId, mutations, signal) => (updatePlanBatch(captain, teamId, mutations, signal));
|
|
398
451
|
const updateStagedPlan = async (captain, teamId, mutation, signal) => (updateStagedPlanBatch(captain, teamId, [mutation], signal));
|
|
399
452
|
const approveStagedTeam = async (captain, teamId, signal) => {
|
|
400
453
|
const workspace = workspaceOf(captain);
|
|
@@ -407,47 +460,22 @@ export function registerAgentTeamsTools(ctx, config) {
|
|
|
407
460
|
// placeholders before transitioning to the stricter running shape.
|
|
408
461
|
fresh.members = fresh.members.filter((member) => member.status !== 'removed');
|
|
409
462
|
validateStagedGraph(fresh, true);
|
|
410
|
-
const
|
|
411
|
-
|
|
412
|
-
const
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
reasoningEffort: member.reasoningEffort,
|
|
420
|
-
fallback: member.fallback,
|
|
421
|
-
}, runSignal);
|
|
422
|
-
selections.set(member, selection);
|
|
423
|
-
member.provider = selection.provider;
|
|
424
|
-
member.model = selection.model;
|
|
425
|
-
member.reasoningEffort = selection.reasoningEffort;
|
|
426
|
-
}
|
|
427
|
-
// This is the approval commit barrier: resolve and validate the whole
|
|
428
|
-
// final roster before spawning even the first durable child.
|
|
429
|
-
await validateMemberLlmSelections(ctx, [...selections.values()], runSignal);
|
|
430
|
-
for (const [member, selection] of selections) {
|
|
431
|
-
await spawnMember(ctx, memberRuntime(config), memberSelections, selection, captain, fresh, member, config.stateDir, runSignal);
|
|
432
|
-
spawned.push(member);
|
|
433
|
-
}
|
|
434
|
-
if (fresh.members.some((member) => member.id === '')) {
|
|
435
|
-
throw new Error('one or more staged members could not be spawned');
|
|
436
|
-
}
|
|
437
|
-
fresh.phase = 'running';
|
|
438
|
-
delete fresh.planReviewState;
|
|
439
|
-
fresh.approvedAt = Date.now();
|
|
440
|
-
await writeTeam(stateRoot, fresh);
|
|
441
|
-
return { teamId: fresh.id, members: fresh.members.length, tasks: fresh.tasks.length };
|
|
442
|
-
}
|
|
443
|
-
catch (error) {
|
|
444
|
-
await recordRetiredMemberIds(stateRoot, spawned.map((member) => member.id)).catch(() => undefined);
|
|
445
|
-
for (const member of spawned) {
|
|
446
|
-
if (member.id !== '')
|
|
447
|
-
interruptMember(ctx, captain, member.id);
|
|
448
|
-
}
|
|
449
|
-
throw error;
|
|
463
|
+
const selections = [];
|
|
464
|
+
for (const member of fresh.members) {
|
|
465
|
+
const selection = await resolveMemberLlmSelection(ctx, captain, {
|
|
466
|
+
provider: member.provider, model: member.model, reasoningEffort: member.reasoningEffort, fallback: member.fallback,
|
|
467
|
+
}, runSignal);
|
|
468
|
+
selections.push(selection);
|
|
469
|
+
member.provider = selection.provider;
|
|
470
|
+
member.model = selection.model;
|
|
471
|
+
member.reasoningEffort = selection.reasoningEffort;
|
|
450
472
|
}
|
|
473
|
+
await validateMemberLlmSelections(ctx, selections, runSignal);
|
|
474
|
+
fresh.phase = 'running';
|
|
475
|
+
delete fresh.planReviewState;
|
|
476
|
+
fresh.approvedAt = Date.now();
|
|
477
|
+
await writeTeam(stateRoot, fresh);
|
|
478
|
+
return { teamId: fresh.id, members: fresh.members.length, tasks: fresh.tasks.length };
|
|
451
479
|
});
|
|
452
480
|
try {
|
|
453
481
|
await scheduler.kickTeam(workspace, teamId, captain);
|
|
@@ -547,6 +575,21 @@ export function registerAgentTeamsTools(ctx, config) {
|
|
|
547
575
|
name: { type: 'string', required: true, description: 'Name for the new team (used as its stable id).' },
|
|
548
576
|
description: { type: 'string', description: 'Team purpose / the goal the team will work on.' },
|
|
549
577
|
profile: { type: 'string', description: 'Optional configured profile name.' },
|
|
578
|
+
plan: {
|
|
579
|
+
type: 'object', additionalProperties: false,
|
|
580
|
+
description: 'Optional complete ordinary-work roster and DAG in one atomic call, instead of separate add_member/create_task rounds. Mutually exclusive with profile. For quality gates use create_task with the explicit quality contract.',
|
|
581
|
+
properties: {
|
|
582
|
+
members: { type: 'array', required: true, items: { type: 'object', additionalProperties: false, properties: {
|
|
583
|
+
name: { type: 'string', required: true }, role: { type: 'string' },
|
|
584
|
+
provider: { type: 'string' }, model: { type: 'string' }, reasoning_effort: { type: 'string' },
|
|
585
|
+
} } },
|
|
586
|
+
tasks: { type: 'array', required: true, items: { type: 'object', additionalProperties: false, properties: {
|
|
587
|
+
id: { type: 'string', required: true, description: 'Local reference used by dependencies in this plan; the result maps it to a durable task id.' },
|
|
588
|
+
subject: { type: 'string', required: true }, description: { type: 'string' }, assignee: { type: 'string' },
|
|
589
|
+
dependencies: { type: 'array', items: { type: 'string' } },
|
|
590
|
+
} } },
|
|
591
|
+
},
|
|
592
|
+
},
|
|
550
593
|
approval: {
|
|
551
594
|
type: 'string',
|
|
552
595
|
enum: ['required', 'automatic'],
|
|
@@ -570,9 +613,10 @@ export function registerAgentTeamsTools(ctx, config) {
|
|
|
570
613
|
},
|
|
571
614
|
render: (args, value) => [{
|
|
572
615
|
type: 'text',
|
|
573
|
-
text: value.phase === 'staged'
|
|
616
|
+
text: (value.phase === 'staged'
|
|
574
617
|
? `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.`
|
|
575
|
-
: `Team "${value.team_name}" created (id ${value.team_id}) under ${value.state_dir}. You are the captain
|
|
618
|
+
: `Team "${value.team_name}" created (id ${value.team_id}) under ${value.state_dir}. You are the captain.`)
|
|
619
|
+
+ (value.tasks === undefined ? '' : '\n' + value.tasks.map(task => `${task.task_id} [${task.seed_id}]: ${task.subject}; assignee=${task.assignee ?? 'unassigned'}; dependencies=${task.dependencies.join(',')}`).join('\n')),
|
|
576
620
|
}],
|
|
577
621
|
},
|
|
578
622
|
async execute(args, exec) {
|
|
@@ -590,6 +634,8 @@ export function registerAgentTeamsTools(ctx, config) {
|
|
|
590
634
|
const profileName = args.profile !== undefined && args.profile.trim() !== ''
|
|
591
635
|
? args.profile.trim()
|
|
592
636
|
: undefined;
|
|
637
|
+
if (profileName !== undefined && args.plan !== undefined)
|
|
638
|
+
throw new Error('choose either a configured profile or an inline plan');
|
|
593
639
|
const created = await withTeamLock(captainLockKey(stateRoot, captain.id), async () => {
|
|
594
640
|
const current = await findTeamByParticipant(stateRoot, captain.id);
|
|
595
641
|
if (current !== undefined) {
|
|
@@ -604,7 +650,7 @@ export function registerAgentTeamsTools(ctx, config) {
|
|
|
604
650
|
if (existing !== undefined) {
|
|
605
651
|
throw new Error(`team id "${teamId}" is taken by another captain — pick a different team name`);
|
|
606
652
|
}
|
|
607
|
-
if (profileName === undefined) {
|
|
653
|
+
if (profileName === undefined && args.plan === undefined) {
|
|
608
654
|
const state = {
|
|
609
655
|
name: teamName,
|
|
610
656
|
id: teamId,
|
|
@@ -628,7 +674,8 @@ export function registerAgentTeamsTools(ctx, config) {
|
|
|
628
674
|
stateRoot,
|
|
629
675
|
teamName,
|
|
630
676
|
teamId,
|
|
631
|
-
profileName,
|
|
677
|
+
profileName: profileName ?? 'inline-plan',
|
|
678
|
+
inlinePlan: args.plan,
|
|
632
679
|
description: args.description,
|
|
633
680
|
staged,
|
|
634
681
|
});
|
|
@@ -673,7 +720,7 @@ export function registerAgentTeamsTools(ctx, config) {
|
|
|
673
720
|
}
|
|
674
721
|
const persisted = await readTeam(stateRoot, created.state.id).catch(() => undefined);
|
|
675
722
|
const snapshot = persisted ?? created.state;
|
|
676
|
-
if (snapshot.profile === undefined) {
|
|
723
|
+
if (snapshot.profile === undefined && args.plan === undefined) {
|
|
677
724
|
return {
|
|
678
725
|
team_id: snapshot.id,
|
|
679
726
|
team_name: snapshot.name,
|
|
@@ -686,8 +733,8 @@ export function registerAgentTeamsTools(ctx, config) {
|
|
|
686
733
|
team_name: snapshot.name,
|
|
687
734
|
state_dir: join(stateRoot, snapshot.id),
|
|
688
735
|
phase: snapshot.phase ?? 'running',
|
|
689
|
-
profile: snapshot.profile.name,
|
|
690
|
-
task_planning: snapshot.profile
|
|
736
|
+
...snapshot.profile === undefined ? {} : { profile: snapshot.profile.name },
|
|
737
|
+
task_planning: snapshot.profile?.taskPlanning ?? 'seed',
|
|
691
738
|
members: snapshot.members.map((member) => ({
|
|
692
739
|
member_name: member.name,
|
|
693
740
|
member_id: member.id,
|
|
@@ -710,12 +757,12 @@ export function registerAgentTeamsTools(ctx, config) {
|
|
|
710
757
|
}));
|
|
711
758
|
ctx.tools.register(defineTool({
|
|
712
759
|
name: 'agent_teams_edit_plan',
|
|
713
|
-
description: 'Atomically revise
|
|
760
|
+
description: 'Atomically revise an AgentTeams plan. While staged, edit tasks and roster without starting work. While running, only update_task is allowed, and only for pending tasks with no prior attempt: correct dependencies, assignees or descriptions before they start; newly ready work is scheduled after commit. Never edit active/finished attempts. Submit dependent edits in order. Never modify .agent-teams files directly.',
|
|
714
761
|
parameters: {
|
|
715
762
|
operations: {
|
|
716
763
|
type: 'array',
|
|
717
764
|
required: true,
|
|
718
|
-
description: 'One atomic, ordered batch
|
|
765
|
+
description: 'One atomic, ordered batch. Running teams allow only update_task for never-started pending tasks. If any operation is invalid, none of the edits are saved.',
|
|
719
766
|
items: {
|
|
720
767
|
type: 'object',
|
|
721
768
|
additionalProperties: false,
|
|
@@ -756,14 +803,13 @@ export function registerAgentTeamsTools(ctx, config) {
|
|
|
756
803
|
},
|
|
757
804
|
render: (_args, value) => [{
|
|
758
805
|
type: 'text',
|
|
759
|
-
text:
|
|
806
|
+
text: `${value.status === 'staged' ? 'Staged plan' : 'Pending task graph'} updated atomically (${value.members} members, ${value.tasks} tasks, ${value.dependencies} dependencies). ${value.status === 'staged' ? 'No members were spawned and no tasks were scheduled.' : 'The scheduler will dispatch any newly ready work.'}\n${value.graph.join('\n')}`,
|
|
760
807
|
}],
|
|
761
808
|
},
|
|
762
809
|
async execute(args, exec) {
|
|
763
810
|
const captain = requireCaptain(exec);
|
|
764
811
|
const workspace = workspaceOf(captain);
|
|
765
812
|
const team = await requireCaptainTeam(workspace, config, captain);
|
|
766
|
-
requireStagedTeam(team);
|
|
767
813
|
if (args.operations.length === 0)
|
|
768
814
|
throw new Error('at least one staged plan operation is required');
|
|
769
815
|
const mutations = args.operations.map((operation, index) => {
|
|
@@ -820,9 +866,11 @@ export function registerAgentTeamsTools(ctx, config) {
|
|
|
820
866
|
throw new Error(`${label} requires member_name`);
|
|
821
867
|
return { action: 'remove_member', memberName };
|
|
822
868
|
});
|
|
823
|
-
const updated = await
|
|
869
|
+
const updated = await updatePlanBatch(captain, team.id, mutations, exec.signal, true);
|
|
870
|
+
if (updated.phase !== 'staged')
|
|
871
|
+
await scheduler.kickTeam(workspace, team.id, captain);
|
|
824
872
|
return {
|
|
825
|
-
status: '
|
|
873
|
+
status: updated.phase ?? 'running',
|
|
826
874
|
team_id: updated.id,
|
|
827
875
|
members: updated.members.length,
|
|
828
876
|
tasks: updated.tasks.length,
|
|
@@ -866,7 +914,7 @@ export function registerAgentTeamsTools(ctx, config) {
|
|
|
866
914
|
}));
|
|
867
915
|
ctx.tools.register(defineTool({
|
|
868
916
|
name: 'agent_teams_add_member',
|
|
869
|
-
description: 'Add a member to the team roster.
|
|
917
|
+
description: 'Add a member to the team roster. Planning and idle roster rows do not call a model. After approval, the member session starts with its first ready task or explicit message and remains durable for later work.',
|
|
870
918
|
parameters: {
|
|
871
919
|
name: { type: 'string', required: true, description: 'Unique member name inside the team.' },
|
|
872
920
|
role: { type: 'string', description: 'Role of the member (e.g. researcher, engineer, reviewer).' },
|
|
@@ -893,7 +941,7 @@ export function registerAgentTeamsTools(ctx, config) {
|
|
|
893
941
|
type: 'text',
|
|
894
942
|
text: value.phase === 'staged'
|
|
895
943
|
? `Member "${value.member_name}" added to the staged roster (${value.provider}/${value.model}); no child was spawned.`
|
|
896
|
-
: `Member "${value.member_name}" added (
|
|
944
|
+
: `Member "${value.member_name}" added (session ${value.member_id || 'starts with first ready task'}, ${value.provider}/${value.model}${value.reasoning_effort === undefined ? '' : `, reasoning ${value.reasoning_effort}`}, status ${value.status}).`,
|
|
897
945
|
}],
|
|
898
946
|
},
|
|
899
947
|
async execute(args, exec) {
|
|
@@ -935,23 +983,9 @@ export function registerAgentTeamsTools(ctx, config) {
|
|
|
935
983
|
joinedAt: Date.now(),
|
|
936
984
|
status: 'idle',
|
|
937
985
|
};
|
|
938
|
-
|
|
939
|
-
await spawnMember(ctx, memberRuntime(config), memberSelections, selection, captain, fresh, member, config.stateDir, exec.signal);
|
|
940
|
-
}
|
|
986
|
+
await validateMemberLlmSelections(ctx, [selection], exec.signal);
|
|
941
987
|
fresh.members.push(member);
|
|
942
|
-
|
|
943
|
-
await writeTeam(stateRoot, fresh);
|
|
944
|
-
}
|
|
945
|
-
catch (error) {
|
|
946
|
-
// The continuable child is already live, but the durable team record
|
|
947
|
-
// never saw it. Retire the orphan so it disappears from subagent
|
|
948
|
-
// listings and cannot be resumed, then surface the write failure.
|
|
949
|
-
if (member.id !== '') {
|
|
950
|
-
await recordRetiredMemberIds(stateRoot, [member.id]).catch(() => undefined);
|
|
951
|
-
interruptMember(ctx, captain, member.id);
|
|
952
|
-
}
|
|
953
|
-
throw error;
|
|
954
|
-
}
|
|
988
|
+
await writeTeam(stateRoot, fresh);
|
|
955
989
|
appendTeamEvent(ctx, captainSessionOf(ctx, fresh.captainSessionId, captain.session), 'agent-teams/member-added', {
|
|
956
990
|
teamId: fresh.id,
|
|
957
991
|
memberId: member.id,
|
|
@@ -971,7 +1005,8 @@ export function registerAgentTeamsTools(ctx, config) {
|
|
|
971
1005
|
};
|
|
972
1006
|
});
|
|
973
1007
|
await scheduler.kickMember(workspace, team.id, created.member_name, captain);
|
|
974
|
-
|
|
1008
|
+
const latest = (await readTeam(stateRoot, team.id))?.members.find(member => member.name === created.member_name);
|
|
1009
|
+
return latest === undefined ? created : { ...created, member_id: latest.id, status: latest.status };
|
|
975
1010
|
},
|
|
976
1011
|
}));
|
|
977
1012
|
ctx.tools.register(defineTool({
|
|
@@ -1002,7 +1037,9 @@ export function registerAgentTeamsTools(ctx, config) {
|
|
|
1002
1037
|
const team = await requireCaptainTeam(workspace, config, captain);
|
|
1003
1038
|
const revoked = await withTeamLock(teamLockKey(stateRoot, team.id), async () => {
|
|
1004
1039
|
const fresh = await requireFreshCaptainTeam(stateRoot, team.id, captain.id);
|
|
1005
|
-
const member =
|
|
1040
|
+
const member = fresh.members.find(item => item.name === args.name);
|
|
1041
|
+
if (member === undefined)
|
|
1042
|
+
throw new Error(`no member \"${args.name}\" in team \"${fresh.name}\"`);
|
|
1006
1043
|
const requeued = [];
|
|
1007
1044
|
for (const task of fresh.tasks) {
|
|
1008
1045
|
if (task.assignee !== member.name || task.status === 'completed')
|
|
@@ -1012,6 +1049,7 @@ export function registerAgentTeamsTools(ctx, config) {
|
|
|
1012
1049
|
requeued.push(task.id);
|
|
1013
1050
|
}
|
|
1014
1051
|
member.status = 'removed';
|
|
1052
|
+
await discardMailboxMessages(stateRoot, fresh.id, member.name, (await readUnreadMailbox(stateRoot, fresh.id, member.name)).map(message => message.id));
|
|
1015
1053
|
await writeTeam(stateRoot, fresh);
|
|
1016
1054
|
appendTeamEvent(ctx, captainSessionOf(ctx, fresh.captainSessionId, captain.session), 'agent-teams/member-removed', {
|
|
1017
1055
|
teamId: fresh.id,
|
|
@@ -1021,8 +1059,7 @@ export function registerAgentTeamsTools(ctx, config) {
|
|
|
1021
1059
|
});
|
|
1022
1060
|
if (revoked.member.id !== '') {
|
|
1023
1061
|
await recordRetiredMemberIds(stateRoot, [revoked.member.id]);
|
|
1024
|
-
|
|
1025
|
-
await waitForMemberIdle(ctx, revoked.member, exec.signal);
|
|
1062
|
+
await stopTeamMemberActivations(ctx, captain, [revoked.member], exec.signal);
|
|
1026
1063
|
}
|
|
1027
1064
|
await scheduler.kickTeam(workspace, team.id, captain);
|
|
1028
1065
|
return {
|
|
@@ -1034,7 +1071,7 @@ export function registerAgentTeamsTools(ctx, config) {
|
|
|
1034
1071
|
}));
|
|
1035
1072
|
ctx.tools.register(defineTool({
|
|
1036
1073
|
name: 'agent_teams_create_task',
|
|
1037
|
-
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.',
|
|
1074
|
+
description: 'Create a task in your team\'s task list. Use kind=work (default) for research, repository audits and general tasks. kind=review is only a quality gate for an existing implementation/repair task via reviewedTaskId. 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.',
|
|
1038
1075
|
parameters: {
|
|
1039
1076
|
subject: { type: 'string', required: true, description: 'Required non-empty title for this task. Never omit it, including for verification or review tasks.' },
|
|
1040
1077
|
description: { type: 'string', description: 'What needs to be done, in detail.' },
|
|
@@ -1043,11 +1080,11 @@ export function registerAgentTeamsTools(ctx, config) {
|
|
|
1043
1080
|
items: { type: 'string' },
|
|
1044
1081
|
description: 'Task ids this task depends on (must be completed before this task can be claimed).',
|
|
1045
1082
|
},
|
|
1046
|
-
assignee: { type: 'string', description: '
|
|
1083
|
+
assignee: { type: 'string', description: 'Member name when an owner is specified. Omission puts this task in the shared pool; roles, subjects and descriptions do not assign an owner.' },
|
|
1047
1084
|
kind: {
|
|
1048
1085
|
type: 'string',
|
|
1049
1086
|
enum: ['work', 'requirements', 'implementation', 'verification', 'review', 'repair', 'integration'],
|
|
1050
|
-
description: '
|
|
1087
|
+
description: 'Explicit task kind. Omission means work with no quality gates, even if the subject says implementation/review. Quality kinds require a contract. An implementation may be planned before requirements passes when its dependency chain includes that requirements task.',
|
|
1051
1088
|
},
|
|
1052
1089
|
round: { type: 'number', description: '1-based review / requirements / repair round.' },
|
|
1053
1090
|
objective: { type: 'string', description: 'Required non-empty objective for quality kinds.' },
|
|
@@ -1057,7 +1094,7 @@ export function registerAgentTeamsTools(ctx, config) {
|
|
|
1057
1094
|
verify: { type: 'array', items: { type: 'string' }, description: 'Verification commands. Required for implementation/repair.' },
|
|
1058
1095
|
deliverables: { type: 'array', items: { type: 'string' }, description: 'Expected deliverable paths or names.' },
|
|
1059
1096
|
nonGoals: { type: 'array', items: { type: 'string' }, description: 'Explicit non-goals.' },
|
|
1060
|
-
reviewedTaskId: { type: 'string', description: '
|
|
1097
|
+
reviewedTaskId: { type: 'string', description: 'Existing AgentTeams implementation/repair task id. Required for kind=review; an external repository audit is kind=work.' },
|
|
1061
1098
|
sourceTaskId: { type: 'string', description: 'Source implementation/artifact. Required for kind=repair.' },
|
|
1062
1099
|
sourceFindingIds: { type: 'array', items: { type: 'string' }, description: 'Finding ids this repair must close.' },
|
|
1063
1100
|
coverageOf: { type: 'array', items: { type: 'string' }, description: 'User-constraint / goal items this task covers.' },
|
|
@@ -1072,12 +1109,13 @@ export function registerAgentTeamsTools(ctx, config) {
|
|
|
1072
1109
|
task_id: { type: 'string', required: true },
|
|
1073
1110
|
subject: { type: 'string', required: true },
|
|
1074
1111
|
status: { type: 'string', required: true },
|
|
1112
|
+
kind: { type: 'string', required: true },
|
|
1075
1113
|
assignee: { type: 'string' },
|
|
1076
1114
|
},
|
|
1077
1115
|
},
|
|
1078
1116
|
render: (args, value) => [{
|
|
1079
1117
|
type: 'text',
|
|
1080
|
-
text: `Task "${value.subject}" created as ${value.task_id} (status ${value.status}${value.assignee ?
|
|
1118
|
+
text: `Task "${value.subject}" created as ${value.task_id} (status ${value.status}, kind ${value.kind}, ${value.assignee ? `assigned to ${value.assignee}` : 'unassigned shared pool'}).`,
|
|
1081
1119
|
}],
|
|
1082
1120
|
},
|
|
1083
1121
|
async execute(args, exec) {
|
|
@@ -1182,6 +1220,7 @@ export function registerAgentTeamsTools(ctx, config) {
|
|
|
1182
1220
|
task_id: task.id,
|
|
1183
1221
|
subject: task.subject,
|
|
1184
1222
|
status: task.status,
|
|
1223
|
+
kind: taskKindOf(task),
|
|
1185
1224
|
...task.assignee !== undefined ? { assignee: task.assignee } : {},
|
|
1186
1225
|
};
|
|
1187
1226
|
});
|
|
@@ -1228,8 +1267,12 @@ export function registerAgentTeamsTools(ctx, config) {
|
|
|
1228
1267
|
const task = requireTask(fresh, args.task_id);
|
|
1229
1268
|
if (task.status === 'completed')
|
|
1230
1269
|
throw new Error(`completed task ${task.id} is immutable and cannot be reassigned`);
|
|
1231
|
-
if (task.reassigning === true)
|
|
1232
|
-
|
|
1270
|
+
if (task.reassigning === true) {
|
|
1271
|
+
const previousMember = fresh.members.find(member => member.id === task.handoffFromMemberId && member.stopping === true);
|
|
1272
|
+
if (task.assignee !== target || previousMember === undefined)
|
|
1273
|
+
throw new Error(`task ${task.id} is already being reassigned`);
|
|
1274
|
+
return { previousAssignee: previousMember.name, previousMember: { ...previousMember }, handoffId: task.handoffId };
|
|
1275
|
+
}
|
|
1233
1276
|
const targetMember = target === CAPTAIN_KEY ? undefined : requireMember(fresh, target);
|
|
1234
1277
|
if (target === CAPTAIN_KEY) {
|
|
1235
1278
|
const busy = captainOpenTask(fresh, task.id);
|
|
@@ -1253,6 +1296,11 @@ export function registerAgentTeamsTools(ctx, config) {
|
|
|
1253
1296
|
? undefined
|
|
1254
1297
|
: fresh.members.find(member => member.name === task.assignee && member.status !== 'removed');
|
|
1255
1298
|
invalidateTaskAttempt(task, target, true);
|
|
1299
|
+
if (previousMember !== undefined) {
|
|
1300
|
+
previousMember.stopping = true;
|
|
1301
|
+
task.handoffFromMemberId = previousMember.id;
|
|
1302
|
+
await discardMailboxMessages(stateRoot, fresh.id, previousMember.name, (await readUnreadMailbox(stateRoot, fresh.id, previousMember.name)).map(message => message.id));
|
|
1303
|
+
}
|
|
1256
1304
|
await writeTeam(stateRoot, fresh);
|
|
1257
1305
|
return {
|
|
1258
1306
|
previousAssignee,
|
|
@@ -1262,9 +1310,8 @@ export function registerAgentTeamsTools(ctx, config) {
|
|
|
1262
1310
|
});
|
|
1263
1311
|
let quiescenceError;
|
|
1264
1312
|
if (revoked.previousMember !== undefined) {
|
|
1265
|
-
interruptMember(ctx, captain, revoked.previousMember.id);
|
|
1266
1313
|
try {
|
|
1267
|
-
await
|
|
1314
|
+
await stopTeamMemberActivations(ctx, captain, [revoked.previousMember], exec.signal);
|
|
1268
1315
|
}
|
|
1269
1316
|
catch (error) {
|
|
1270
1317
|
quiescenceError = error;
|
|
@@ -1276,7 +1323,13 @@ export function registerAgentTeamsTools(ctx, config) {
|
|
|
1276
1323
|
if (task.handoffId !== revoked.handoffId || task.assignee !== target || task.reassigning !== true) {
|
|
1277
1324
|
throw new Error(`task ${task.id} changed during reassignment; refusing to overwrite the newer state`);
|
|
1278
1325
|
}
|
|
1279
|
-
task.reassigning =
|
|
1326
|
+
task.reassigning = quiescenceError !== undefined;
|
|
1327
|
+
if (quiescenceError === undefined) {
|
|
1328
|
+
const previous = fresh.members.find(member => member.id === revoked.previousMember?.id);
|
|
1329
|
+
if (previous !== undefined)
|
|
1330
|
+
delete previous.stopping;
|
|
1331
|
+
delete task.handoffFromMemberId;
|
|
1332
|
+
}
|
|
1280
1333
|
if (quiescenceError === undefined && target === CAPTAIN_KEY) {
|
|
1281
1334
|
beginTaskAttempt(task, CAPTAIN_KEY);
|
|
1282
1335
|
// The captain is already in the turn that requested takeover; there
|
|
@@ -1328,11 +1381,12 @@ export function registerAgentTeamsTools(ctx, config) {
|
|
|
1328
1381
|
assignee: { type: 'string', required: true },
|
|
1329
1382
|
attempt: { type: 'number', required: true },
|
|
1330
1383
|
attempt_id: { type: 'string' },
|
|
1384
|
+
task_details: { type: 'string', required: true },
|
|
1331
1385
|
},
|
|
1332
1386
|
},
|
|
1333
1387
|
render: (args, value) => [{
|
|
1334
1388
|
type: 'text',
|
|
1335
|
-
text: `Task ${value.task_id} claimed by ${value.assignee} (attempt ${value.attempt}${value.attempt_id ? `, attempt_id ${value.attempt_id}` : ''}, status ${value.status})
|
|
1389
|
+
text: `Task ${value.task_id} claimed by ${value.assignee} (attempt ${value.attempt}${value.attempt_id ? `, attempt_id ${value.attempt_id}` : ''}, status ${value.status}).\n${value.task_details}`,
|
|
1336
1390
|
}],
|
|
1337
1391
|
},
|
|
1338
1392
|
async execute(args, exec) {
|
|
@@ -1371,6 +1425,7 @@ export function registerAgentTeamsTools(ctx, config) {
|
|
|
1371
1425
|
throw new Error(`task ${task.id} is already claimed by "${task.assignee ?? 'nobody'}"`);
|
|
1372
1426
|
}
|
|
1373
1427
|
return {
|
|
1428
|
+
task_details: taskDetails(fresh, task),
|
|
1374
1429
|
task_id: task.id,
|
|
1375
1430
|
status: task.status,
|
|
1376
1431
|
assignee,
|
|
@@ -1401,6 +1456,7 @@ export function registerAgentTeamsTools(ctx, config) {
|
|
|
1401
1456
|
assignee: task.assignee,
|
|
1402
1457
|
});
|
|
1403
1458
|
return {
|
|
1459
|
+
task_details: taskDetails(fresh, task),
|
|
1404
1460
|
task_id: task.id,
|
|
1405
1461
|
status: task.status,
|
|
1406
1462
|
assignee: task.assignee ?? '',
|
|
@@ -1415,13 +1471,13 @@ export function registerAgentTeamsTools(ctx, config) {
|
|
|
1415
1471
|
description: 'Update a task status/output. Members must supply the current attempt_id returned by claim_task; stale attempts are rejected after takeover/reassignment. Terminal results are immutable. A captain must use reassign_task(assignee="captain") before updating member-owned work.',
|
|
1416
1472
|
parameters: {
|
|
1417
1473
|
task_id: { type: 'string', required: true, description: 'The task id to update.' },
|
|
1474
|
+
attempt_id: { type: 'string', description: 'Members must explicitly include the current attempt_id from their assignment/claim in EVERY update, including failed reviews with findings. If omitted, retry with the same current id; omission does not revoke the attempt.' },
|
|
1418
1475
|
status: {
|
|
1419
1476
|
type: 'string',
|
|
1420
1477
|
enum: ['in_progress', 'completed', 'failed', 'cancelled'],
|
|
1421
1478
|
description: 'New status (in_progress, completed, failed, cancelled).',
|
|
1422
1479
|
},
|
|
1423
1480
|
output: { type: 'string', description: 'Result summary; set when completing or failing.' },
|
|
1424
|
-
attempt_id: { type: 'string', description: 'Current execution capability returned by claim_task (required for members when present on the task).' },
|
|
1425
1481
|
verdict: {
|
|
1426
1482
|
type: 'string',
|
|
1427
1483
|
enum: ['pass', 'needs_revision', 'reject'],
|
|
@@ -1504,13 +1560,17 @@ export function registerAgentTeamsTools(ctx, config) {
|
|
|
1504
1560
|
const task = requireTask(fresh, args.task_id);
|
|
1505
1561
|
if (identity.kind === 'captain'
|
|
1506
1562
|
&& task.assignee !== undefined
|
|
1507
|
-
&& task.assignee !== CAPTAIN_KEY
|
|
1563
|
+
&& task.assignee !== CAPTAIN_KEY
|
|
1564
|
+
&& !(args.status === 'cancelled' && task.status === 'pending' && (task.attempt ?? 0) === 0 && task.reassigning !== true)) {
|
|
1508
1565
|
throw new Error(`task ${task.id} is owned by member "${task.assignee}"; call agent_teams_reassign_task with assignee="captain" before takeover`);
|
|
1509
1566
|
}
|
|
1510
1567
|
if (identity.kind === 'member') {
|
|
1511
1568
|
if (task.assignee !== identity.name) {
|
|
1512
1569
|
throw new Error(`task ${task.id} is assigned to "${task.assignee ?? 'nobody'}", not you`);
|
|
1513
1570
|
}
|
|
1571
|
+
if (task.attemptId !== undefined && (args.attempt_id === undefined || args.attempt_id.trim() === '')) {
|
|
1572
|
+
throw new Error(`missing attempt_id for task ${task.id}. Retry this update with attempt_id="${task.attemptId}" from your current assignment. This is a missing parameter, not a revoked attempt; do not restart the work or request reassignment.`);
|
|
1573
|
+
}
|
|
1514
1574
|
if (task.attemptId !== undefined && args.attempt_id !== task.attemptId) {
|
|
1515
1575
|
throw new Error(`stale attempt for task ${task.id}: expected the current attempt_id; stop work and request fresh assignment`);
|
|
1516
1576
|
}
|
|
@@ -1605,9 +1665,89 @@ export function registerAgentTeamsTools(ctx, config) {
|
|
|
1605
1665
|
return updated;
|
|
1606
1666
|
},
|
|
1607
1667
|
}));
|
|
1668
|
+
ctx.tools.register(defineTool({
|
|
1669
|
+
name: 'agent_teams_amend_task',
|
|
1670
|
+
description: 'Captain-only controlled contract amendment for one non-terminal quality task: replace a wrong objective/acceptance/verify/inScope/outOfScope when the original contract makes honest completion impossible (for example a verify command that cannot pass, or an inScope that forbids the file the objective names). The amendment is appended to the task\'s revisions ledger with previous values and the reason, and is rejected once a review/requirements task has passed judgment on this task. Members cannot amend contracts; the implementer re-reads the amended contract before its next quality gate. Lists are full replacements, not deltas.',
|
|
1671
|
+
parameters: {
|
|
1672
|
+
task_id: { type: 'string', required: true, description: 'Task whose contract is being amended.' },
|
|
1673
|
+
reason: { type: 'string', required: true, description: 'Why the current contract is wrong; recorded in the revisions ledger.' },
|
|
1674
|
+
objective: { type: 'string', description: 'Replacement objective.' },
|
|
1675
|
+
acceptance: { type: 'array', items: { type: 'string' }, description: 'Replacement acceptance criteria (full list, not a delta).' },
|
|
1676
|
+
verify: { type: 'array', items: { type: 'string' }, description: 'Replacement verification commands (full list, not a delta).' },
|
|
1677
|
+
inScope: { type: 'array', items: { type: 'string' }, description: 'Replacement workspace-relative inScope paths (full list).' },
|
|
1678
|
+
outOfScope: { type: 'array', items: { type: 'string' }, description: 'Replacement workspace-relative outOfScope paths (full list).' },
|
|
1679
|
+
},
|
|
1680
|
+
output: {
|
|
1681
|
+
schema: {
|
|
1682
|
+
type: 'object',
|
|
1683
|
+
additionalProperties: false,
|
|
1684
|
+
properties: {
|
|
1685
|
+
task_id: { type: 'string', required: true },
|
|
1686
|
+
status: { type: 'string', required: true },
|
|
1687
|
+
revised_fields: { type: 'string', required: true },
|
|
1688
|
+
revision_count: { type: 'number', required: true },
|
|
1689
|
+
contract: { type: 'string', required: true },
|
|
1690
|
+
},
|
|
1691
|
+
},
|
|
1692
|
+
render: (_args, value) => [{
|
|
1693
|
+
type: 'text',
|
|
1694
|
+
text: `Task ${value.task_id} contract amended (${value.revised_fields}); ${value.revision_count} revision(s) on record, status ${value.status}. New contract: ${value.contract}`,
|
|
1695
|
+
}],
|
|
1696
|
+
},
|
|
1697
|
+
async execute(args, exec) {
|
|
1698
|
+
const captain = requireCaptain(exec);
|
|
1699
|
+
const workspace = workspaceOf(captain);
|
|
1700
|
+
const stateRoot = stateRootOf(workspace, config);
|
|
1701
|
+
const team = await requireCaptainTeam(workspace, config, captain);
|
|
1702
|
+
const amended = await withTeamLock(teamLockKey(stateRoot, team.id), async () => {
|
|
1703
|
+
const fresh = await requireFreshCaptainTeam(stateRoot, team.id, captain.id);
|
|
1704
|
+
const task = requireTask(fresh, args.task_id);
|
|
1705
|
+
const input = {
|
|
1706
|
+
...args.objective === undefined ? {} : { objective: args.objective },
|
|
1707
|
+
...args.acceptance === undefined ? {} : { acceptance: args.acceptance },
|
|
1708
|
+
...args.verify === undefined ? {} : { verify: args.verify },
|
|
1709
|
+
...args.inScope === undefined ? {} : { inScope: args.inScope },
|
|
1710
|
+
...args.outOfScope === undefined ? {} : { outOfScope: args.outOfScope },
|
|
1711
|
+
};
|
|
1712
|
+
const result = amendTaskContract(fresh, task, normalizeBlankOptionalTaskFields(input), CAPTAIN_KEY, args.reason);
|
|
1713
|
+
if (!result.ok || result.task === undefined) {
|
|
1714
|
+
throw new Error(result.error ?? 'amend_task rejected by quality gates');
|
|
1715
|
+
}
|
|
1716
|
+
Object.assign(task, result.task);
|
|
1717
|
+
task.updatedAt = Date.now();
|
|
1718
|
+
await writeTeam(stateRoot, fresh);
|
|
1719
|
+
return {
|
|
1720
|
+
taskId: task.id,
|
|
1721
|
+
status: task.status,
|
|
1722
|
+
fields: result.revision?.fields ?? [],
|
|
1723
|
+
revisionCount: task.revisions?.length ?? 0,
|
|
1724
|
+
contract: {
|
|
1725
|
+
...task.objective === undefined ? {} : { objective: task.objective },
|
|
1726
|
+
...task.acceptance === undefined ? {} : { acceptance: task.acceptance },
|
|
1727
|
+
...task.verify === undefined ? {} : { verify: task.verify },
|
|
1728
|
+
...task.inScope === undefined ? {} : { inScope: task.inScope },
|
|
1729
|
+
...task.outOfScope === undefined ? {} : { outOfScope: task.outOfScope },
|
|
1730
|
+
},
|
|
1731
|
+
};
|
|
1732
|
+
});
|
|
1733
|
+
appendTeamEvent(ctx, captainSessionOf(ctx, team.captainSessionId, captain.session), 'agent-teams/task-amended', {
|
|
1734
|
+
teamId: team.id,
|
|
1735
|
+
taskId: amended.taskId,
|
|
1736
|
+
fields: amended.fields,
|
|
1737
|
+
reason: args.reason,
|
|
1738
|
+
});
|
|
1739
|
+
return {
|
|
1740
|
+
task_id: amended.taskId,
|
|
1741
|
+
status: amended.status,
|
|
1742
|
+
revised_fields: amended.fields.join(', '),
|
|
1743
|
+
revision_count: amended.revisionCount,
|
|
1744
|
+
contract: JSON.stringify(amended.contract),
|
|
1745
|
+
};
|
|
1746
|
+
},
|
|
1747
|
+
}));
|
|
1608
1748
|
ctx.tools.register(defineTool({
|
|
1609
1749
|
name: 'agent_teams_send_message',
|
|
1610
|
-
description: 'Send
|
|
1750
|
+
description: 'Send coordination or current-task guidance directly to the captain or a teammate. A running recipient receives it at the next model step; an idle recipient wakes. Messages are durably retained until read. Use task creation/reassignment for a new unit of work, not repeated status nudges.',
|
|
1611
1751
|
parameters: {
|
|
1612
1752
|
to: { type: 'string', required: true, description: 'Recipient: "captain" or a member name.' },
|
|
1613
1753
|
content: { type: 'string', required: true, description: 'The message text.' },
|
|
@@ -1660,7 +1800,10 @@ export function registerAgentTeamsTools(ctx, config) {
|
|
|
1660
1800
|
throw new Error(`team "${fresh.name}" is halted; call agent_teams_resume before waking a member`);
|
|
1661
1801
|
}
|
|
1662
1802
|
const recipient = requireMember(fresh, to);
|
|
1663
|
-
const
|
|
1803
|
+
const owned = memberOpenTask(fresh, recipient.name);
|
|
1804
|
+
const message = { ...createMessage(from, recipient.name, args.content), deliveryClaimedAt: Date.now(),
|
|
1805
|
+
...owned?.attemptId === undefined ? {} : { taskId: owned.id, attemptId: owned.attemptId },
|
|
1806
|
+
};
|
|
1664
1807
|
await appendMailbox(stateRoot, fresh.id, recipient.name, message);
|
|
1665
1808
|
appendTeamEvent(ctx, captainSessionOf(ctx, fresh.captainSessionId, caller.session), 'agent-teams/message-sent', {
|
|
1666
1809
|
teamId: fresh.id,
|
|
@@ -1678,10 +1821,10 @@ export function registerAgentTeamsTools(ctx, config) {
|
|
|
1678
1821
|
if (prepared.kind === 'captain') {
|
|
1679
1822
|
let delivered = 'mailbox';
|
|
1680
1823
|
if (captain !== undefined && prepared.identity.kind === 'member') {
|
|
1681
|
-
delivered = steerCaptainReport(captain, prepared.from, args.content) ? 'live' : 'mailbox';
|
|
1824
|
+
delivered = steerCaptainReport(captain, prepared.from, args.content, mailboxPrompt(prepared.fresh.id, CAPTAIN_KEY, [prepared.message])) ? 'live' : 'mailbox';
|
|
1682
1825
|
}
|
|
1683
1826
|
if (delivered === 'live') {
|
|
1684
|
-
await withTeamLock(teamLockKey(stateRoot, prepared.fresh.id), () => (
|
|
1827
|
+
await withTeamLock(teamLockKey(stateRoot, prepared.fresh.id), () => (markMailboxDelivered(stateRoot, prepared.fresh.id, CAPTAIN_KEY, [prepared.message.id])));
|
|
1685
1828
|
}
|
|
1686
1829
|
else {
|
|
1687
1830
|
await withTeamLock(teamLockKey(stateRoot, prepared.fresh.id), () => (releaseMailboxDelivery(stateRoot, prepared.fresh.id, CAPTAIN_KEY, [prepared.message.id])));
|
|
@@ -1689,15 +1832,12 @@ export function registerAgentTeamsTools(ctx, config) {
|
|
|
1689
1832
|
return { message_id: prepared.message.id, from: prepared.from, to: CAPTAIN_KEY, delivered };
|
|
1690
1833
|
}
|
|
1691
1834
|
let delivered = 'mailbox';
|
|
1692
|
-
if (captain !== undefined
|
|
1693
|
-
const
|
|
1694
|
-
|
|
1695
|
-
: `Message from team member ${prepared.from}:\n\n${args.content}`;
|
|
1696
|
-
const text = `AgentTeams state policy: inspect ${config.stateDir}/${prepared.fresh.id}/ read-only; never edit team.json or inbox files directly. Use agent_teams_* tools for team state.\n\n${senderText}`;
|
|
1697
|
-
const accepted = await deliverToMember(ctx, captain, prepared.recipient.id, text, exec.signal);
|
|
1835
|
+
if (captain !== undefined) {
|
|
1836
|
+
const text = mailboxPrompt(prepared.fresh.id, prepared.recipient.name, [prepared.message]);
|
|
1837
|
+
const accepted = await dispatchMember(captain, prepared.fresh.id, prepared.recipient.name, text, exec.signal, 'steer', prepared.message.attemptId);
|
|
1698
1838
|
delivered = accepted ? 'wake' : 'mailbox';
|
|
1699
1839
|
if (accepted) {
|
|
1700
|
-
await withTeamLock(teamLockKey(stateRoot, prepared.fresh.id), () => (
|
|
1840
|
+
await withTeamLock(teamLockKey(stateRoot, prepared.fresh.id), () => (markMailboxDelivered(stateRoot, prepared.fresh.id, prepared.recipient.name, [prepared.message.id])));
|
|
1701
1841
|
}
|
|
1702
1842
|
}
|
|
1703
1843
|
if (delivered === 'mailbox') {
|
|
@@ -1739,6 +1879,7 @@ export function registerAgentTeamsTools(ctx, config) {
|
|
|
1739
1879
|
reasoning_effort: member.reasoningEffort ?? '',
|
|
1740
1880
|
status: member.status,
|
|
1741
1881
|
activity: member.id !== '' ? (activity.get(member.id) ?? 'unknown') : 'unspawned',
|
|
1882
|
+
...member.spawnError === undefined ? {} : { spawn_error: member.spawnError },
|
|
1742
1883
|
}));
|
|
1743
1884
|
const tasks = team.tasks.map((task) => ({
|
|
1744
1885
|
id: task.id,
|
|
@@ -1767,6 +1908,7 @@ export function registerAgentTeamsTools(ctx, config) {
|
|
|
1767
1908
|
const captainInbox = identity.kind === 'captain'
|
|
1768
1909
|
? await readUnreadMailbox(stateRoot, team.id, CAPTAIN_KEY, reportMalformed(CAPTAIN_KEY))
|
|
1769
1910
|
: [];
|
|
1911
|
+
const ownInbox = identity.kind === 'member' ? (await readUnreadMailbox(stateRoot, team.id, identity.name)).slice(0, 10) : [];
|
|
1770
1912
|
const memberInboxes = {};
|
|
1771
1913
|
const visibleMembers = identity.kind === 'captain'
|
|
1772
1914
|
? members
|
|
@@ -1813,18 +1955,19 @@ export function registerAgentTeamsTools(ctx, config) {
|
|
|
1813
1955
|
viewer: identity.name,
|
|
1814
1956
|
members,
|
|
1815
1957
|
tasks,
|
|
1816
|
-
captain_inbox: captainInbox.slice(
|
|
1958
|
+
captain_inbox: captainInbox.slice(0, 10).map((message) => ({
|
|
1817
1959
|
from: message.from,
|
|
1818
1960
|
content: message.content,
|
|
1819
1961
|
ts: message.ts,
|
|
1820
1962
|
})),
|
|
1963
|
+
member_inbox: ownInbox.map(message => ({ from: message.from, content: message.content, ts: message.ts })),
|
|
1821
1964
|
member_inboxes: memberInboxes,
|
|
1822
1965
|
mailbox_warnings: mailboxWarnings,
|
|
1823
1966
|
mailbox_warning_count: mailboxWarningCount,
|
|
1824
1967
|
};
|
|
1825
1968
|
const acknowledged = identity.kind === 'captain'
|
|
1826
|
-
? captainInbox.map(message => message.id)
|
|
1827
|
-
:
|
|
1969
|
+
? captainInbox.slice(0, 10).map(message => message.id)
|
|
1970
|
+
: ownInbox.map(message => message.id);
|
|
1828
1971
|
if (acknowledged.length > 0) {
|
|
1829
1972
|
await withTeamLock(teamLockKey(stateRoot, team.id), () => (acknowledgeMailbox(stateRoot, team.id, identity.kind === 'captain' ? CAPTAIN_KEY : identity.name, acknowledged)));
|
|
1830
1973
|
}
|
|
@@ -1913,6 +2056,7 @@ export function registerAgentTeamsTools(ctx, config) {
|
|
|
1913
2056
|
// retires durable catalog entries left behind by remove_member.
|
|
1914
2057
|
const roster = fresh.members.map(member => ({ ...member }));
|
|
1915
2058
|
for (const member of fresh.members) {
|
|
2059
|
+
await discardMailboxMessages(stateRoot, fresh.id, member.name, (await readUnreadMailbox(stateRoot, fresh.id, member.name)).map(message => message.id));
|
|
1916
2060
|
if (member.status === 'removed')
|
|
1917
2061
|
continue;
|
|
1918
2062
|
member.status = 'removed';
|
|
@@ -1925,17 +2069,7 @@ export function registerAgentTeamsTools(ctx, config) {
|
|
|
1925
2069
|
return roster;
|
|
1926
2070
|
});
|
|
1927
2071
|
await recordRetiredMemberIds(stateRoot, members.map(member => member.id));
|
|
1928
|
-
|
|
1929
|
-
if (member.id === '')
|
|
1930
|
-
continue;
|
|
1931
|
-
interruptMember(ctx, captain, member.id);
|
|
1932
|
-
}
|
|
1933
|
-
const quiescence = await Promise.allSettled(members.map(member => waitForMemberIdle(ctx, member, exec.signal)));
|
|
1934
|
-
for (const result of quiescence) {
|
|
1935
|
-
if (result.status === 'rejected') {
|
|
1936
|
-
ctx.logger.warn(`agent-teams: member did not quiesce cleanly before team archive: ${String(result.reason)}`);
|
|
1937
|
-
}
|
|
1938
|
-
}
|
|
2072
|
+
await stopTeamMemberActivations(ctx, captain, members, exec.signal);
|
|
1939
2073
|
await withTeamLock(teamLockKey(stateRoot, team.id), async () => {
|
|
1940
2074
|
const fresh = await requireFreshCaptainTeam(stateRoot, team.id, captain.id);
|
|
1941
2075
|
appendTeamEvent(ctx, captainSessionOf(ctx, fresh.captainSessionId, captain.session), 'agent-teams/team-deleted', {
|
|
@@ -1951,7 +2085,7 @@ export function registerAgentTeamsTools(ctx, config) {
|
|
|
1951
2085
|
return runtime;
|
|
1952
2086
|
}
|
|
1953
2087
|
async function initializeProfileTeam(input) {
|
|
1954
|
-
const profile = resolveTeamProfile(input.config.profiles, input.profileName, input.config.maxMembers);
|
|
2088
|
+
const profile = resolveTeamProfile(input.inlinePlan === undefined ? input.config.profiles : { [input.profileName]: input.inlinePlan }, input.profileName, input.config.maxMembers);
|
|
1955
2089
|
const selections = [];
|
|
1956
2090
|
for (const template of profile.members) {
|
|
1957
2091
|
selections.push(await resolveMemberLlmSelection(input.ctx, input.captain, {
|
|
@@ -2011,50 +2145,12 @@ async function initializeProfileTeam(input) {
|
|
|
2011
2145
|
})),
|
|
2012
2146
|
taskSeq: profile.tasks.length,
|
|
2013
2147
|
};
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
for (const member of draft.members) {
|
|
2021
|
-
const selection = selections[spawned.length];
|
|
2022
|
-
await spawnMember(input.ctx, memberRuntime(input.config), input.memberSelections, selection, input.captain, draft, member, input.config.stateDir, input.exec.signal);
|
|
2023
|
-
spawned.push(member);
|
|
2024
|
-
}
|
|
2025
|
-
if (draft.members.some((member) => member.id === '')) {
|
|
2026
|
-
throw new Error(`failed to initialize profile "${profile.name}": a spawned member is missing its child id`);
|
|
2027
|
-
}
|
|
2028
|
-
await createTeamDir(input.stateRoot, draft);
|
|
2029
|
-
return { committed: true, state: draft };
|
|
2030
|
-
}
|
|
2031
|
-
catch (error) {
|
|
2032
|
-
const cleanupErrors = [];
|
|
2033
|
-
try {
|
|
2034
|
-
await removeTeamDir(input.stateRoot, draft.id);
|
|
2035
|
-
}
|
|
2036
|
-
catch (cleanupError) {
|
|
2037
|
-
cleanupErrors.push(cleanupError);
|
|
2038
|
-
}
|
|
2039
|
-
try {
|
|
2040
|
-
await recordRetiredMemberIds(input.stateRoot, spawned.map((member) => member.id));
|
|
2041
|
-
}
|
|
2042
|
-
catch (cleanupError) {
|
|
2043
|
-
cleanupErrors.push(cleanupError);
|
|
2044
|
-
}
|
|
2045
|
-
for (const member of spawned) {
|
|
2046
|
-
try {
|
|
2047
|
-
interruptMember(input.ctx, input.captain, member.id);
|
|
2048
|
-
}
|
|
2049
|
-
catch (cleanupError) {
|
|
2050
|
-
cleanupErrors.push(cleanupError);
|
|
2051
|
-
}
|
|
2052
|
-
}
|
|
2053
|
-
if (cleanupErrors.length > 0) {
|
|
2054
|
-
throw new AggregateError([error, ...cleanupErrors], `failed to initialize profile "${profile.name}"`);
|
|
2055
|
-
}
|
|
2056
|
-
throw error;
|
|
2057
|
-
}
|
|
2148
|
+
// Roster creation is durable planning only. The scheduler starts each
|
|
2149
|
+
// member with its first actual task once its dependencies are satisfied.
|
|
2150
|
+
if (input.inlinePlan !== undefined)
|
|
2151
|
+
delete draft.profile;
|
|
2152
|
+
await createTeamDir(input.stateRoot, draft);
|
|
2153
|
+
return { committed: true, state: draft };
|
|
2058
2154
|
}
|
|
2059
2155
|
function parseFindings(value) {
|
|
2060
2156
|
if (value === undefined)
|
|
@@ -2223,7 +2319,8 @@ function renderStatus(value) {
|
|
|
2223
2319
|
...team.members.map((member) => {
|
|
2224
2320
|
const route = member.provider && member.model ? ` · ${member.provider}/${member.model}` : '';
|
|
2225
2321
|
const effort = member.reasoning_effort ? ` · reasoning ${member.reasoning_effort}` : '';
|
|
2226
|
-
|
|
2322
|
+
const failure = member.spawn_error === undefined ? '' : `\n start failed: ${member.spawn_error.slice(0, 400)}`;
|
|
2323
|
+
return ` - ${member.name} [${member.role}] ${member.status}/${member.activity}${route}${effort}${failure}`;
|
|
2227
2324
|
}),
|
|
2228
2325
|
`Tasks (${team.tasks.length}):`,
|
|
2229
2326
|
...team.tasks.map((task) => {
|
|
@@ -2244,7 +2341,8 @@ function renderStatus(value) {
|
|
|
2244
2341
|
`Delivery: ${team.delivery.ok ? 'ok' : `blocked (${team.delivery.blockers.join('; ')})`}`,
|
|
2245
2342
|
],
|
|
2246
2343
|
`Captain inbox (${team.captain_inbox.length}):`,
|
|
2247
|
-
...team.captain_inbox.map((message) => ` - [${message.from}] ${message.content
|
|
2344
|
+
...team.captain_inbox.map((message) => ` - [${message.from}] ${message.content}`),
|
|
2345
|
+
...(team.member_inbox ?? []).map(message => ` - [${message.from}] ${message.content}`),
|
|
2248
2346
|
];
|
|
2249
2347
|
for (const [name, inbox] of Object.entries(team.member_inboxes)) {
|
|
2250
2348
|
lines.push(`Member inbox ${name} (${inbox.count}): latest — ${inbox.latest.slice(0, 120)}`);
|