@nanmicoder/dsh-agent-teams 0.1.5 → 0.1.7
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 +8 -7
- package/README_ZH.md +8 -7
- package/lib/client/ActivityPanel.js +93 -38
- package/lib/client/activity-model.js +63 -0
- package/lib/client.js +478 -367
- package/lib/client.js.map +1 -1
- package/lib/index.js +7 -5
- package/lib/members.js +101 -18
- package/lib/scheduler.js +212 -0
- package/lib/snapshot.js +32 -19
- package/lib/state.js +275 -5
- package/lib/tools.js +303 -31
- package/lib/types/client/ActivityPanel.d.ts +1 -0
- package/lib/types/client/activity-model.d.ts +34 -0
- package/lib/types/event-types.d.ts +2 -0
- package/lib/types/members.d.ts +25 -8
- package/lib/types/scheduler.d.ts +23 -0
- package/lib/types/snapshot.d.ts +10 -2
- package/lib/types/state.d.ts +53 -0
- package/lib/types/types.d.ts +16 -2
- package/package.json +2 -2
package/lib/tools.js
CHANGED
|
@@ -12,8 +12,10 @@ 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 { appendMailbox, archiveTeamDir, CAPTAIN_KEY, createMessage, createTeamDir, findTeamByCaptain, findTeamByParticipant,
|
|
16
|
-
import { deliverToMember, installMemberSelectionRuntime, interruptMember, memberActivity, resolveMemberLlmSelection, spawnMember, } from "./members.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";
|
|
17
|
+
import { TERMINAL_TASK_STATUSES } from "./types.js";
|
|
18
|
+
import { installTeamScheduler } from "./scheduler.js";
|
|
17
19
|
/** The caller agent, or a loud failure for non-agent callers. */
|
|
18
20
|
function requireCaptain(exec) {
|
|
19
21
|
if (!exec.agent) {
|
|
@@ -99,6 +101,31 @@ function requireTask(team, taskId) {
|
|
|
99
101
|
}
|
|
100
102
|
return task;
|
|
101
103
|
}
|
|
104
|
+
function memberOpenTask(team, memberName, exceptTaskId) {
|
|
105
|
+
return team.tasks.find(task => task.id !== exceptTaskId
|
|
106
|
+
&& task.assignee === memberName
|
|
107
|
+
&& (task.status === 'claimed' || task.status === 'in_progress'));
|
|
108
|
+
}
|
|
109
|
+
async function waitForMemberIdle(ctx, member, signal) {
|
|
110
|
+
if (member.id === '')
|
|
111
|
+
return;
|
|
112
|
+
const live = ctx.agents.get(member.id);
|
|
113
|
+
if (live === undefined)
|
|
114
|
+
return;
|
|
115
|
+
if (signal.aborted)
|
|
116
|
+
throw signal.reason;
|
|
117
|
+
let onAbort;
|
|
118
|
+
const aborted = new Promise((_resolve, reject) => {
|
|
119
|
+
onAbort = () => reject(signal.reason ?? new Error('task reassignment was cancelled'));
|
|
120
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
121
|
+
});
|
|
122
|
+
try {
|
|
123
|
+
await Promise.race([live.whenIdle(), aborted]);
|
|
124
|
+
}
|
|
125
|
+
finally {
|
|
126
|
+
signal.removeEventListener('abort', onAbort);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
102
129
|
/**
|
|
103
130
|
* Deliver a durable member report at the captain's nearest model boundary.
|
|
104
131
|
*
|
|
@@ -126,7 +153,9 @@ export function steerCaptainReport(captain, from, content) {
|
|
|
126
153
|
* @param config - resolved tool config.
|
|
127
154
|
*/
|
|
128
155
|
export function registerAgentTeamsTools(ctx, config) {
|
|
156
|
+
installRetiredMemberGuard(ctx, config.stateDir);
|
|
129
157
|
const memberSelections = installMemberSelectionRuntime(ctx, config.stateDir);
|
|
158
|
+
const scheduler = installTeamScheduler(ctx, { stateDir: config.stateDir });
|
|
130
159
|
ctx.tools.register(defineTool({
|
|
131
160
|
name: 'agent_teams_create',
|
|
132
161
|
description: 'Create a new AgentTeams team: you (the calling agent) become the captain. A captain leads one team at a time; create tasks and members afterwards with agent_teams_add_member and agent_teams_create_task.',
|
|
@@ -192,12 +221,13 @@ export function registerAgentTeamsTools(ctx, config) {
|
|
|
192
221
|
}));
|
|
193
222
|
ctx.tools.register(defineTool({
|
|
194
223
|
name: 'agent_teams_add_member',
|
|
195
|
-
description: 'Add a durable continuable member. By default it snapshots the captain\'s current LLM
|
|
224
|
+
description: 'Add a durable continuable member. By default it snapshots the captain\'s current LLM route and effort. Supply provider/model only for an explicitly requested role-specific route; a changed provider or model automatically uses the target model\'s default effort. Set reasoning_effort only to request one of the target model\'s supported ids explicitly (or "default" to force its default). The member waits for messages, works on assigned tasks, and can message the team.',
|
|
196
225
|
parameters: {
|
|
197
226
|
name: { type: 'string', required: true, description: 'Unique member name inside the team.' },
|
|
198
227
|
role: { type: 'string', description: 'Role of the member (e.g. researcher, engineer, reviewer).' },
|
|
199
228
|
provider: { type: 'string', description: 'Optional LLM provider route. Use only when the user explicitly requests a different provider; requires model.' },
|
|
200
229
|
model: { type: 'string', description: 'Optional model override. Omit for the captain\'s current model (or the configured memberModel default).' },
|
|
230
|
+
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.' },
|
|
201
231
|
},
|
|
202
232
|
output: {
|
|
203
233
|
schema: {
|
|
@@ -222,7 +252,7 @@ export function registerAgentTeamsTools(ctx, config) {
|
|
|
222
252
|
const workspace = workspaceOf(captain);
|
|
223
253
|
const stateRoot = stateRootOf(workspace, config);
|
|
224
254
|
const team = await requireCaptainTeam(workspace, config, captain);
|
|
225
|
-
|
|
255
|
+
const created = await withTeamLock(teamLockKey(stateRoot, team.id), async () => {
|
|
226
256
|
const fresh = await requireFreshCaptainTeam(stateRoot, team.id, captain.id);
|
|
227
257
|
const memberName = args.name.trim();
|
|
228
258
|
if (memberName === '')
|
|
@@ -241,6 +271,7 @@ export function registerAgentTeamsTools(ctx, config) {
|
|
|
241
271
|
provider: args.provider,
|
|
242
272
|
model: args.model,
|
|
243
273
|
defaultModel: config.memberModel,
|
|
274
|
+
reasoningEffort: args.reasoning_effort,
|
|
244
275
|
}, exec.signal);
|
|
245
276
|
const member = {
|
|
246
277
|
id: '',
|
|
@@ -254,7 +285,19 @@ export function registerAgentTeamsTools(ctx, config) {
|
|
|
254
285
|
};
|
|
255
286
|
await spawnMember(ctx, memberRuntime(config), memberSelections, selection, captain, fresh, member, config.stateDir, exec.signal);
|
|
256
287
|
fresh.members.push(member);
|
|
257
|
-
|
|
288
|
+
try {
|
|
289
|
+
await writeTeam(stateRoot, fresh);
|
|
290
|
+
}
|
|
291
|
+
catch (error) {
|
|
292
|
+
// The continuable child is already live, but the durable team record
|
|
293
|
+
// never saw it. Retire the orphan so it disappears from subagent
|
|
294
|
+
// listings and cannot be resumed, then surface the write failure.
|
|
295
|
+
if (member.id !== '') {
|
|
296
|
+
await recordRetiredMemberIds(stateRoot, [member.id]).catch(() => undefined);
|
|
297
|
+
interruptMember(ctx, captain, member.id);
|
|
298
|
+
}
|
|
299
|
+
throw error;
|
|
300
|
+
}
|
|
258
301
|
appendTeamEvent(ctx, captainSessionOf(ctx, fresh.captainSessionId, captain.session), 'agent-teams/member-added', {
|
|
259
302
|
teamId: fresh.id,
|
|
260
303
|
memberId: member.id,
|
|
@@ -272,11 +315,13 @@ export function registerAgentTeamsTools(ctx, config) {
|
|
|
272
315
|
status: member.status,
|
|
273
316
|
};
|
|
274
317
|
});
|
|
318
|
+
await scheduler.kickMember(workspace, team.id, created.member_name, captain);
|
|
319
|
+
return created;
|
|
275
320
|
},
|
|
276
321
|
}));
|
|
277
322
|
ctx.tools.register(defineTool({
|
|
278
323
|
name: 'agent_teams_remove_member',
|
|
279
|
-
description: 'Remove a member
|
|
324
|
+
description: 'Remove a member safely: revoke its current attempts, return all unfinished owned tasks to the shared pending pool, interrupt its live turn, and mark it removed.',
|
|
280
325
|
parameters: {
|
|
281
326
|
name: { type: 'string', required: true, description: 'Name of the member to remove.' },
|
|
282
327
|
},
|
|
@@ -287,11 +332,12 @@ export function registerAgentTeamsTools(ctx, config) {
|
|
|
287
332
|
properties: {
|
|
288
333
|
member_name: { type: 'string', required: true },
|
|
289
334
|
status: { type: 'string', required: true },
|
|
335
|
+
requeued_tasks: { type: 'array', items: { type: 'string' }, required: true },
|
|
290
336
|
},
|
|
291
337
|
},
|
|
292
338
|
render: (args, value) => [{
|
|
293
339
|
type: 'text',
|
|
294
|
-
text: `Member "${value.member_name}" removed (status ${value.status}).`,
|
|
340
|
+
text: `Member "${value.member_name}" removed (status ${value.status}); requeued tasks: ${value.requeued_tasks.join(', ') || 'none'}.`,
|
|
295
341
|
}],
|
|
296
342
|
},
|
|
297
343
|
async execute(args, exec) {
|
|
@@ -299,19 +345,36 @@ export function registerAgentTeamsTools(ctx, config) {
|
|
|
299
345
|
const workspace = workspaceOf(captain);
|
|
300
346
|
const stateRoot = stateRootOf(workspace, config);
|
|
301
347
|
const team = await requireCaptainTeam(workspace, config, captain);
|
|
302
|
-
|
|
348
|
+
const revoked = await withTeamLock(teamLockKey(stateRoot, team.id), async () => {
|
|
303
349
|
const fresh = await requireFreshCaptainTeam(stateRoot, team.id, captain.id);
|
|
304
350
|
const member = requireMember(fresh, args.name);
|
|
305
|
-
|
|
306
|
-
|
|
351
|
+
const requeued = [];
|
|
352
|
+
for (const task of fresh.tasks) {
|
|
353
|
+
if (task.assignee !== member.name || task.status === 'completed')
|
|
354
|
+
continue;
|
|
355
|
+
invalidateTaskAttempt(task);
|
|
356
|
+
task.reassigning = false;
|
|
357
|
+
requeued.push(task.id);
|
|
358
|
+
}
|
|
307
359
|
member.status = 'removed';
|
|
308
360
|
await writeTeam(stateRoot, fresh);
|
|
309
361
|
appendTeamEvent(ctx, captainSessionOf(ctx, fresh.captainSessionId, captain.session), 'agent-teams/member-removed', {
|
|
310
362
|
teamId: fresh.id,
|
|
311
363
|
memberId: member.id,
|
|
312
364
|
});
|
|
313
|
-
return {
|
|
365
|
+
return { member: { ...member }, requeued };
|
|
314
366
|
});
|
|
367
|
+
if (revoked.member.id !== '') {
|
|
368
|
+
await recordRetiredMemberIds(stateRoot, [revoked.member.id]);
|
|
369
|
+
interruptMember(ctx, captain, revoked.member.id);
|
|
370
|
+
await waitForMemberIdle(ctx, revoked.member, exec.signal);
|
|
371
|
+
}
|
|
372
|
+
await scheduler.kickTeam(workspace, team.id, captain);
|
|
373
|
+
return {
|
|
374
|
+
member_name: revoked.member.name,
|
|
375
|
+
status: revoked.member.status,
|
|
376
|
+
requeued_tasks: revoked.requeued,
|
|
377
|
+
};
|
|
315
378
|
},
|
|
316
379
|
}));
|
|
317
380
|
ctx.tools.register(defineTool({
|
|
@@ -348,7 +411,7 @@ export function registerAgentTeamsTools(ctx, config) {
|
|
|
348
411
|
const workspace = workspaceOf(captain);
|
|
349
412
|
const stateRoot = stateRootOf(workspace, config);
|
|
350
413
|
const team = await requireCaptainTeam(workspace, config, captain);
|
|
351
|
-
|
|
414
|
+
const created = await withTeamLock(teamLockKey(stateRoot, team.id), async () => {
|
|
352
415
|
const fresh = await requireFreshCaptainTeam(stateRoot, team.id, captain.id);
|
|
353
416
|
const dependencies = args.dependencies ?? [];
|
|
354
417
|
for (const dependency of dependencies) {
|
|
@@ -365,6 +428,7 @@ export function registerAgentTeamsTools(ctx, config) {
|
|
|
365
428
|
status: 'pending',
|
|
366
429
|
assignee: args.assignee,
|
|
367
430
|
dependencies,
|
|
431
|
+
attempt: 0,
|
|
368
432
|
createdAt: Date.now(),
|
|
369
433
|
updatedAt: Date.now(),
|
|
370
434
|
};
|
|
@@ -385,11 +449,120 @@ export function registerAgentTeamsTools(ctx, config) {
|
|
|
385
449
|
...task.assignee !== undefined ? { assignee: task.assignee } : {},
|
|
386
450
|
};
|
|
387
451
|
});
|
|
452
|
+
await scheduler.kickTeam(workspace, team.id, captain);
|
|
453
|
+
return created;
|
|
454
|
+
},
|
|
455
|
+
}));
|
|
456
|
+
ctx.tools.register(defineTool({
|
|
457
|
+
name: 'agent_teams_reassign_task',
|
|
458
|
+
description: 'Atomically retry, reassign, or let the captain take over any unfinished/failed task. The old attempt is revoked before its member is interrupted, so late updates cannot overwrite the new owner. Use assignee="captain" for captain takeover.',
|
|
459
|
+
parameters: {
|
|
460
|
+
task_id: { type: 'string', required: true, description: 'Task to retry/reassign.' },
|
|
461
|
+
assignee: { type: 'string', required: true, description: 'Active member name, or "captain" for captain takeover.' },
|
|
462
|
+
reason: { type: 'string', description: 'Why the task is being retried or reassigned.' },
|
|
463
|
+
},
|
|
464
|
+
output: {
|
|
465
|
+
schema: {
|
|
466
|
+
type: 'object',
|
|
467
|
+
additionalProperties: false,
|
|
468
|
+
properties: {
|
|
469
|
+
task_id: { type: 'string', required: true },
|
|
470
|
+
previous_assignee: { type: 'string', required: true },
|
|
471
|
+
assignee: { type: 'string', required: true },
|
|
472
|
+
status: { type: 'string', required: true },
|
|
473
|
+
attempt: { type: 'number', required: true },
|
|
474
|
+
attempt_id: { type: 'string' },
|
|
475
|
+
},
|
|
476
|
+
},
|
|
477
|
+
render: (_args, value) => [{
|
|
478
|
+
type: 'text',
|
|
479
|
+
text: `Task ${value.task_id} reassigned ${value.previous_assignee || 'unassigned'} → ${value.assignee} (attempt ${value.attempt}, status ${value.status}${value.attempt_id ? `, attempt_id ${value.attempt_id}` : ''}).`,
|
|
480
|
+
}],
|
|
481
|
+
},
|
|
482
|
+
async execute(args, exec) {
|
|
483
|
+
const captain = requireCaptain(exec);
|
|
484
|
+
const workspace = workspaceOf(captain);
|
|
485
|
+
const stateRoot = stateRootOf(workspace, config);
|
|
486
|
+
const team = await requireCaptainTeam(workspace, config, captain);
|
|
487
|
+
const target = args.assignee.trim();
|
|
488
|
+
if (target === '')
|
|
489
|
+
throw new Error('reassignment assignee must not be empty');
|
|
490
|
+
const revoked = await withTeamLock(teamLockKey(stateRoot, team.id), async () => {
|
|
491
|
+
const fresh = await requireFreshCaptainTeam(stateRoot, team.id, captain.id);
|
|
492
|
+
const task = requireTask(fresh, args.task_id);
|
|
493
|
+
if (task.status === 'completed')
|
|
494
|
+
throw new Error(`completed task ${task.id} is immutable and cannot be reassigned`);
|
|
495
|
+
if (task.reassigning === true)
|
|
496
|
+
throw new Error(`task ${task.id} is already being reassigned`);
|
|
497
|
+
const targetMember = target === CAPTAIN_KEY ? undefined : requireMember(fresh, target);
|
|
498
|
+
if (targetMember !== undefined) {
|
|
499
|
+
const busy = memberOpenTask(fresh, targetMember.name, task.id);
|
|
500
|
+
if (busy !== undefined) {
|
|
501
|
+
throw new Error(`member "${targetMember.name}" is busy with ${busy.id}; finish or reassign it first`);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
const previousAssignee = task.assignee ?? '';
|
|
505
|
+
const previousMember = (task.status !== 'claimed' && task.status !== 'in_progress')
|
|
506
|
+
|| task.assignee === undefined || task.assignee === CAPTAIN_KEY
|
|
507
|
+
? undefined
|
|
508
|
+
: fresh.members.find(member => member.name === task.assignee && member.status !== 'removed');
|
|
509
|
+
invalidateTaskAttempt(task, target, true);
|
|
510
|
+
await writeTeam(stateRoot, fresh);
|
|
511
|
+
return {
|
|
512
|
+
previousAssignee,
|
|
513
|
+
previousMember: previousMember === undefined ? undefined : { ...previousMember },
|
|
514
|
+
handoffId: task.handoffId,
|
|
515
|
+
};
|
|
516
|
+
});
|
|
517
|
+
let quiescenceError;
|
|
518
|
+
if (revoked.previousMember !== undefined) {
|
|
519
|
+
interruptMember(ctx, captain, revoked.previousMember.id);
|
|
520
|
+
try {
|
|
521
|
+
await waitForMemberIdle(ctx, revoked.previousMember, exec.signal);
|
|
522
|
+
}
|
|
523
|
+
catch (error) {
|
|
524
|
+
quiescenceError = error;
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
await withTeamLock(teamLockKey(stateRoot, team.id), async () => {
|
|
528
|
+
const fresh = await requireFreshCaptainTeam(stateRoot, team.id, captain.id);
|
|
529
|
+
const task = requireTask(fresh, args.task_id);
|
|
530
|
+
if (task.handoffId !== revoked.handoffId || task.assignee !== target || task.reassigning !== true) {
|
|
531
|
+
throw new Error(`task ${task.id} changed during reassignment; refusing to overwrite the newer state`);
|
|
532
|
+
}
|
|
533
|
+
task.reassigning = false;
|
|
534
|
+
if (quiescenceError === undefined && target === CAPTAIN_KEY)
|
|
535
|
+
beginTaskAttempt(task, CAPTAIN_KEY);
|
|
536
|
+
await writeTeam(stateRoot, fresh);
|
|
537
|
+
appendTeamEvent(ctx, captain.session, 'agent-teams/task-updated', {
|
|
538
|
+
teamId: fresh.id,
|
|
539
|
+
taskId: task.id,
|
|
540
|
+
status: task.status,
|
|
541
|
+
assignee: task.assignee,
|
|
542
|
+
...args.reason === undefined ? {} : { output: `Reassigned: ${args.reason}` },
|
|
543
|
+
});
|
|
544
|
+
});
|
|
545
|
+
if (quiescenceError !== undefined)
|
|
546
|
+
throw quiescenceError;
|
|
547
|
+
if (target !== CAPTAIN_KEY)
|
|
548
|
+
await scheduler.kickMember(workspace, team.id, target, captain);
|
|
549
|
+
const current = await readTeam(stateRoot, team.id);
|
|
550
|
+
const task = current === undefined ? undefined : requireTask(current, args.task_id);
|
|
551
|
+
if (task === undefined)
|
|
552
|
+
throw new Error(`team "${team.name}" ended during reassignment`);
|
|
553
|
+
return {
|
|
554
|
+
task_id: task.id,
|
|
555
|
+
previous_assignee: revoked.previousAssignee,
|
|
556
|
+
assignee: task.assignee ?? '',
|
|
557
|
+
status: task.status,
|
|
558
|
+
attempt: task.attempt ?? 0,
|
|
559
|
+
...task.attemptId === undefined ? {} : { attempt_id: task.attemptId },
|
|
560
|
+
};
|
|
388
561
|
},
|
|
389
562
|
}));
|
|
390
563
|
ctx.tools.register(defineTool({
|
|
391
564
|
name: 'agent_teams_claim_task',
|
|
392
|
-
description: 'Claim
|
|
565
|
+
description: 'Claim one ready task for a member (or yourself). A member cannot own a second unfinished task. The returned attempt_id is required for that member\'s updates and becomes stale after retry/reassignment.',
|
|
393
566
|
parameters: {
|
|
394
567
|
task_id: { type: 'string', required: true, description: 'The task id to claim.' },
|
|
395
568
|
assignee: { type: 'string', description: 'Member to claim for (captain only; defaults to the task\'s assignee).' },
|
|
@@ -402,11 +575,13 @@ export function registerAgentTeamsTools(ctx, config) {
|
|
|
402
575
|
task_id: { type: 'string', required: true },
|
|
403
576
|
status: { type: 'string', required: true },
|
|
404
577
|
assignee: { type: 'string', required: true },
|
|
578
|
+
attempt: { type: 'number', required: true },
|
|
579
|
+
attempt_id: { type: 'string' },
|
|
405
580
|
},
|
|
406
581
|
},
|
|
407
582
|
render: (args, value) => [{
|
|
408
583
|
type: 'text',
|
|
409
|
-
text: `Task ${value.task_id} claimed by ${value.assignee} (status ${value.status}).`,
|
|
584
|
+
text: `Task ${value.task_id} claimed by ${value.assignee} (attempt ${value.attempt}${value.attempt_id ? `, attempt_id ${value.attempt_id}` : ''}, status ${value.status}).`,
|
|
410
585
|
}],
|
|
411
586
|
},
|
|
412
587
|
async execute(args, exec) {
|
|
@@ -417,6 +592,9 @@ export function registerAgentTeamsTools(ctx, config) {
|
|
|
417
592
|
return withTeamLock(teamLockKey(stateRoot, team.id), async () => {
|
|
418
593
|
const { team: fresh, identity } = await requireFreshParticipant(stateRoot, team.id, caller.id);
|
|
419
594
|
const task = requireTask(fresh, args.task_id);
|
|
595
|
+
if (task.reassigning === true) {
|
|
596
|
+
throw new Error(`task ${task.id} is being reassigned; wait for the handoff to finish`);
|
|
597
|
+
}
|
|
420
598
|
let assignee = task.assignee;
|
|
421
599
|
if (identity.kind === 'captain') {
|
|
422
600
|
if (args.assignee !== undefined) {
|
|
@@ -439,7 +617,13 @@ export function registerAgentTeamsTools(ctx, config) {
|
|
|
439
617
|
if (assignee === undefined || task.assignee !== assignee) {
|
|
440
618
|
throw new Error(`task ${task.id} is already claimed by "${task.assignee ?? 'nobody'}"`);
|
|
441
619
|
}
|
|
442
|
-
return {
|
|
620
|
+
return {
|
|
621
|
+
task_id: task.id,
|
|
622
|
+
status: task.status,
|
|
623
|
+
assignee,
|
|
624
|
+
attempt: task.attempt ?? 0,
|
|
625
|
+
...task.attemptId === undefined ? {} : { attempt_id: task.attemptId },
|
|
626
|
+
};
|
|
443
627
|
}
|
|
444
628
|
const pending = unsatisfiedDependencies(fresh.tasks, task.dependencies);
|
|
445
629
|
if (pending.length > 0) {
|
|
@@ -451,9 +635,11 @@ export function registerAgentTeamsTools(ctx, config) {
|
|
|
451
635
|
if (assignee === undefined) {
|
|
452
636
|
throw new Error('claiming an unassigned task needs an assignee (claim on behalf of a member)');
|
|
453
637
|
}
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
638
|
+
const busy = memberOpenTask(fresh, assignee, task.id);
|
|
639
|
+
if (busy !== undefined) {
|
|
640
|
+
throw new Error(`member "${assignee}" is busy with ${busy.id}; finish or reassign it first`);
|
|
641
|
+
}
|
|
642
|
+
const attemptId = beginTaskAttempt(task, assignee);
|
|
457
643
|
await writeTeam(stateRoot, fresh);
|
|
458
644
|
appendTeamEvent(ctx, captainSessionOf(ctx, fresh.captainSessionId, caller.session), 'agent-teams/task-updated', {
|
|
459
645
|
teamId: fresh.id,
|
|
@@ -461,13 +647,19 @@ export function registerAgentTeamsTools(ctx, config) {
|
|
|
461
647
|
status: task.status,
|
|
462
648
|
assignee: task.assignee,
|
|
463
649
|
});
|
|
464
|
-
return {
|
|
650
|
+
return {
|
|
651
|
+
task_id: task.id,
|
|
652
|
+
status: task.status,
|
|
653
|
+
assignee: task.assignee ?? '',
|
|
654
|
+
attempt: task.attempt ?? 0,
|
|
655
|
+
attempt_id: attemptId,
|
|
656
|
+
};
|
|
465
657
|
});
|
|
466
658
|
},
|
|
467
659
|
}));
|
|
468
660
|
ctx.tools.register(defineTool({
|
|
469
661
|
name: 'agent_teams_update_task',
|
|
470
|
-
description: 'Update a task
|
|
662
|
+
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.',
|
|
471
663
|
parameters: {
|
|
472
664
|
task_id: { type: 'string', required: true, description: 'The task id to update.' },
|
|
473
665
|
status: {
|
|
@@ -476,6 +668,7 @@ export function registerAgentTeamsTools(ctx, config) {
|
|
|
476
668
|
description: 'New status (in_progress, completed, failed, cancelled).',
|
|
477
669
|
},
|
|
478
670
|
output: { type: 'string', description: 'Result summary; set when completing or failing.' },
|
|
671
|
+
attempt_id: { type: 'string', description: 'Current execution capability returned by claim_task (required for members when present on the task).' },
|
|
479
672
|
},
|
|
480
673
|
output: {
|
|
481
674
|
schema: {
|
|
@@ -485,11 +678,13 @@ export function registerAgentTeamsTools(ctx, config) {
|
|
|
485
678
|
task_id: { type: 'string', required: true },
|
|
486
679
|
status: { type: 'string', required: true },
|
|
487
680
|
output: { type: 'string' },
|
|
681
|
+
attempt: { type: 'number', required: true },
|
|
682
|
+
attempt_id: { type: 'string' },
|
|
488
683
|
},
|
|
489
684
|
},
|
|
490
685
|
render: (args, value) => [{
|
|
491
686
|
type: 'text',
|
|
492
|
-
text: `Task ${value.task_id} → ${value.status}${value.output !== undefined ? `\nOutput: ${value.output}` : ''}`,
|
|
687
|
+
text: `Task ${value.task_id} attempt ${value.attempt} → ${value.status}${value.output !== undefined ? `\nOutput: ${value.output}` : ''}`,
|
|
493
688
|
}],
|
|
494
689
|
},
|
|
495
690
|
async execute(args, exec) {
|
|
@@ -497,13 +692,35 @@ export function registerAgentTeamsTools(ctx, config) {
|
|
|
497
692
|
const workspace = workspaceOf(caller);
|
|
498
693
|
const stateRoot = stateRootOf(workspace, config);
|
|
499
694
|
const team = await requireParticipantTeam(workspace, config, caller);
|
|
500
|
-
|
|
695
|
+
const updated = await withTeamLock(teamLockKey(stateRoot, team.id), async () => {
|
|
501
696
|
const { team: fresh, identity } = await requireFreshParticipant(stateRoot, team.id, caller.id);
|
|
502
697
|
const task = requireTask(fresh, args.task_id);
|
|
698
|
+
if (identity.kind === 'captain'
|
|
699
|
+
&& task.assignee !== undefined
|
|
700
|
+
&& task.assignee !== CAPTAIN_KEY) {
|
|
701
|
+
throw new Error(`task ${task.id} is owned by member "${task.assignee}"; call agent_teams_reassign_task with assignee="captain" before takeover`);
|
|
702
|
+
}
|
|
503
703
|
if (identity.kind === 'member') {
|
|
504
704
|
if (task.assignee !== identity.name) {
|
|
505
705
|
throw new Error(`task ${task.id} is assigned to "${task.assignee ?? 'nobody'}", not you`);
|
|
506
706
|
}
|
|
707
|
+
if (task.attemptId !== undefined && args.attempt_id !== task.attemptId) {
|
|
708
|
+
throw new Error(`stale attempt for task ${task.id}: expected the current attempt_id; stop work and request fresh assignment`);
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
if (TERMINAL_TASK_STATUSES.includes(task.status)) {
|
|
712
|
+
const sameStatus = args.status === undefined || args.status === task.status;
|
|
713
|
+
const sameOutput = args.output === undefined || args.output === task.output;
|
|
714
|
+
if (!sameStatus || !sameOutput) {
|
|
715
|
+
throw new Error(`terminal task ${task.id} is immutable; use agent_teams_reassign_task to retry failed/cancelled work`);
|
|
716
|
+
}
|
|
717
|
+
return {
|
|
718
|
+
task_id: task.id,
|
|
719
|
+
status: task.status,
|
|
720
|
+
attempt: task.attempt ?? 0,
|
|
721
|
+
...task.attemptId === undefined ? {} : { attempt_id: task.attemptId },
|
|
722
|
+
...task.output !== undefined ? { output: task.output } : {},
|
|
723
|
+
};
|
|
507
724
|
}
|
|
508
725
|
if (args.status !== undefined) {
|
|
509
726
|
const transition = transitionError(task.status, args.status);
|
|
@@ -525,9 +742,13 @@ export function registerAgentTeamsTools(ctx, config) {
|
|
|
525
742
|
return {
|
|
526
743
|
task_id: task.id,
|
|
527
744
|
status: task.status,
|
|
745
|
+
attempt: task.attempt ?? 0,
|
|
746
|
+
...task.attemptId === undefined ? {} : { attempt_id: task.attemptId },
|
|
528
747
|
...task.output !== undefined ? { output: task.output } : {},
|
|
529
748
|
};
|
|
530
749
|
});
|
|
750
|
+
await scheduler.kickTeam(workspace, team.id, team.captainSessionId === caller.id ? caller : undefined);
|
|
751
|
+
return updated;
|
|
531
752
|
},
|
|
532
753
|
}));
|
|
533
754
|
ctx.tools.register(defineTool({
|
|
@@ -569,7 +790,7 @@ export function registerAgentTeamsTools(ctx, config) {
|
|
|
569
790
|
throw new Error(`agent_teams_send_message: "from" must be your own identity ("${from}"), not "${args.from}"`);
|
|
570
791
|
}
|
|
571
792
|
if (to === CAPTAIN_KEY) {
|
|
572
|
-
const message = createMessage(from, CAPTAIN_KEY, args.content);
|
|
793
|
+
const message = { ...createMessage(from, CAPTAIN_KEY, args.content), deliveryClaimedAt: Date.now() };
|
|
573
794
|
await appendMailbox(stateRoot, fresh.id, CAPTAIN_KEY, message);
|
|
574
795
|
appendTeamEvent(ctx, captainSessionOf(ctx, fresh.captainSessionId, caller.session), 'agent-teams/message-sent', {
|
|
575
796
|
teamId: fresh.id,
|
|
@@ -582,7 +803,7 @@ export function registerAgentTeamsTools(ctx, config) {
|
|
|
582
803
|
return { kind: 'captain', fresh, identity, message, from };
|
|
583
804
|
}
|
|
584
805
|
const recipient = requireMember(fresh, to);
|
|
585
|
-
const message = createMessage(from, recipient.name, args.content);
|
|
806
|
+
const message = { ...createMessage(from, recipient.name, args.content), deliveryClaimedAt: Date.now() };
|
|
586
807
|
await appendMailbox(stateRoot, fresh.id, recipient.name, message);
|
|
587
808
|
appendTeamEvent(ctx, captainSessionOf(ctx, fresh.captainSessionId, caller.session), 'agent-teams/message-sent', {
|
|
588
809
|
teamId: fresh.id,
|
|
@@ -602,6 +823,12 @@ export function registerAgentTeamsTools(ctx, config) {
|
|
|
602
823
|
if (captain !== undefined && prepared.identity.kind === 'member') {
|
|
603
824
|
delivered = steerCaptainReport(captain, prepared.from, args.content) ? 'live' : 'mailbox';
|
|
604
825
|
}
|
|
826
|
+
if (delivered === 'live') {
|
|
827
|
+
await withTeamLock(teamLockKey(stateRoot, prepared.fresh.id), () => (acknowledgeMailbox(stateRoot, prepared.fresh.id, CAPTAIN_KEY, [prepared.message.id])));
|
|
828
|
+
}
|
|
829
|
+
else {
|
|
830
|
+
await withTeamLock(teamLockKey(stateRoot, prepared.fresh.id), () => (releaseMailboxDelivery(stateRoot, prepared.fresh.id, CAPTAIN_KEY, [prepared.message.id])));
|
|
831
|
+
}
|
|
605
832
|
return { message_id: prepared.message.id, from: prepared.from, to: CAPTAIN_KEY, delivered };
|
|
606
833
|
}
|
|
607
834
|
let delivered = 'mailbox';
|
|
@@ -612,6 +839,12 @@ export function registerAgentTeamsTools(ctx, config) {
|
|
|
612
839
|
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}`;
|
|
613
840
|
const accepted = await deliverToMember(ctx, captain, prepared.recipient.id, text, exec.signal);
|
|
614
841
|
delivered = accepted ? 'wake' : 'mailbox';
|
|
842
|
+
if (accepted) {
|
|
843
|
+
await withTeamLock(teamLockKey(stateRoot, prepared.fresh.id), () => (acknowledgeMailbox(stateRoot, prepared.fresh.id, prepared.recipient.name, [prepared.message.id])));
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
if (delivered === 'mailbox') {
|
|
847
|
+
await withTeamLock(teamLockKey(stateRoot, prepared.fresh.id), () => (releaseMailboxDelivery(stateRoot, prepared.fresh.id, prepared.recipient.name, [prepared.message.id])));
|
|
615
848
|
}
|
|
616
849
|
return {
|
|
617
850
|
message_id: prepared.message.id,
|
|
@@ -634,6 +867,9 @@ export function registerAgentTeamsTools(ctx, config) {
|
|
|
634
867
|
const workspace = workspaceOf(caller);
|
|
635
868
|
const stateRoot = stateRootOf(workspace, config);
|
|
636
869
|
const located = await requireParticipantTeam(workspace, config, caller);
|
|
870
|
+
if (located.captainSessionId === caller.id) {
|
|
871
|
+
await scheduler.kickTeam(workspace, located.id, caller);
|
|
872
|
+
}
|
|
637
873
|
const { team, identity } = await withTeamLock(teamLockKey(stateRoot, located.id), () => requireFreshParticipant(stateRoot, located.id, caller.id));
|
|
638
874
|
const activity = await memberActivity(ctx, team.captainSessionId);
|
|
639
875
|
const members = team.members
|
|
@@ -653,6 +889,9 @@ export function registerAgentTeamsTools(ctx, config) {
|
|
|
653
889
|
status: task.status,
|
|
654
890
|
assignee: task.assignee ?? '',
|
|
655
891
|
dependencies: task.dependencies,
|
|
892
|
+
attempt: task.attempt ?? 0,
|
|
893
|
+
attempt_id: task.attemptId ?? '',
|
|
894
|
+
reassigning: task.reassigning === true,
|
|
656
895
|
...task.output !== undefined ? { output: task.output } : {},
|
|
657
896
|
}));
|
|
658
897
|
const mailboxWarnings = [];
|
|
@@ -664,14 +903,14 @@ export function registerAgentTeamsTools(ctx, config) {
|
|
|
664
903
|
}
|
|
665
904
|
};
|
|
666
905
|
const captainInbox = identity.kind === 'captain'
|
|
667
|
-
? await
|
|
906
|
+
? await readUnreadMailbox(stateRoot, team.id, CAPTAIN_KEY, reportMalformed(CAPTAIN_KEY))
|
|
668
907
|
: [];
|
|
669
908
|
const memberInboxes = {};
|
|
670
909
|
const visibleMembers = identity.kind === 'captain'
|
|
671
910
|
? members
|
|
672
911
|
: members.filter((member) => member.name === identity.name);
|
|
673
912
|
for (const member of visibleMembers) {
|
|
674
|
-
const messages = await
|
|
913
|
+
const messages = await readUnreadMailbox(stateRoot, team.id, member.name, reportMalformed(member.name));
|
|
675
914
|
if (messages.length > 0) {
|
|
676
915
|
memberInboxes[member.name] = {
|
|
677
916
|
count: messages.length,
|
|
@@ -679,7 +918,7 @@ export function registerAgentTeamsTools(ctx, config) {
|
|
|
679
918
|
};
|
|
680
919
|
}
|
|
681
920
|
}
|
|
682
|
-
|
|
921
|
+
const result = {
|
|
683
922
|
team_id: team.id,
|
|
684
923
|
team_name: team.name,
|
|
685
924
|
description: team.description ?? '',
|
|
@@ -695,6 +934,13 @@ export function registerAgentTeamsTools(ctx, config) {
|
|
|
695
934
|
mailbox_warnings: mailboxWarnings,
|
|
696
935
|
mailbox_warning_count: mailboxWarningCount,
|
|
697
936
|
};
|
|
937
|
+
const acknowledged = identity.kind === 'captain'
|
|
938
|
+
? captainInbox.map(message => message.id)
|
|
939
|
+
: await readUnreadMailbox(stateRoot, team.id, identity.name).then(messages => messages.map(message => message.id));
|
|
940
|
+
if (acknowledged.length > 0) {
|
|
941
|
+
await withTeamLock(teamLockKey(stateRoot, team.id), () => (acknowledgeMailbox(stateRoot, team.id, identity.kind === 'captain' ? CAPTAIN_KEY : identity.name, acknowledged)));
|
|
942
|
+
}
|
|
943
|
+
return result;
|
|
698
944
|
},
|
|
699
945
|
}));
|
|
700
946
|
ctx.tools.register(defineTool({
|
|
@@ -720,12 +966,37 @@ export function registerAgentTeamsTools(ctx, config) {
|
|
|
720
966
|
const workspace = workspaceOf(captain);
|
|
721
967
|
const stateRoot = stateRootOf(workspace, config);
|
|
722
968
|
const team = await requireCaptainTeam(workspace, config, captain);
|
|
723
|
-
await withTeamLock(teamLockKey(stateRoot, team.id), async () => {
|
|
969
|
+
const members = await withTeamLock(teamLockKey(stateRoot, team.id), async () => {
|
|
724
970
|
const fresh = await requireFreshCaptainTeam(stateRoot, team.id, captain.id);
|
|
971
|
+
// Include previously removed members so deleting a pre-fix team also
|
|
972
|
+
// retires durable catalog entries left behind by remove_member.
|
|
973
|
+
const roster = fresh.members.map(member => ({ ...member }));
|
|
725
974
|
for (const member of fresh.members) {
|
|
726
|
-
if (member.status
|
|
727
|
-
|
|
975
|
+
if (member.status === 'removed')
|
|
976
|
+
continue;
|
|
977
|
+
member.status = 'removed';
|
|
978
|
+
for (const task of fresh.tasks) {
|
|
979
|
+
if (task.assignee === member.name && task.status !== 'completed')
|
|
980
|
+
invalidateTaskAttempt(task);
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
await writeTeam(stateRoot, fresh);
|
|
984
|
+
return roster;
|
|
985
|
+
});
|
|
986
|
+
await recordRetiredMemberIds(stateRoot, members.map(member => member.id));
|
|
987
|
+
for (const member of members) {
|
|
988
|
+
if (member.id === '')
|
|
989
|
+
continue;
|
|
990
|
+
interruptMember(ctx, captain, member.id);
|
|
991
|
+
}
|
|
992
|
+
const quiescence = await Promise.allSettled(members.map(member => waitForMemberIdle(ctx, member, exec.signal)));
|
|
993
|
+
for (const result of quiescence) {
|
|
994
|
+
if (result.status === 'rejected') {
|
|
995
|
+
ctx.logger.warn(`agent-teams: member did not quiesce cleanly before team archive: ${String(result.reason)}`);
|
|
728
996
|
}
|
|
997
|
+
}
|
|
998
|
+
await withTeamLock(teamLockKey(stateRoot, team.id), async () => {
|
|
999
|
+
const fresh = await requireFreshCaptainTeam(stateRoot, team.id, captain.id);
|
|
729
1000
|
appendTeamEvent(ctx, captainSessionOf(ctx, fresh.captainSessionId, captain.session), 'agent-teams/team-deleted', {
|
|
730
1001
|
teamId: fresh.id,
|
|
731
1002
|
});
|
|
@@ -760,7 +1031,8 @@ function renderStatus(value) {
|
|
|
760
1031
|
...team.tasks.map((task) => {
|
|
761
1032
|
const deps = task.dependencies.length > 0 ? ` (deps: ${task.dependencies.join(',')})` : '';
|
|
762
1033
|
const output = task.output !== undefined ? `\n output: ${task.output.slice(0, 300)}` : '';
|
|
763
|
-
|
|
1034
|
+
const handoff = task.reassigning ? ' (reassigning)' : '';
|
|
1035
|
+
return ` - ${task.id} [${task.status}] attempt ${task.attempt}${handoff} ${task.subject} → ${task.assignee || 'unassigned'}${deps}${output}`;
|
|
764
1036
|
}),
|
|
765
1037
|
`Captain inbox (${team.captain_inbox.length}):`,
|
|
766
1038
|
...team.captain_inbox.map((message) => ` - [${message.from}] ${message.content.slice(0, 200)}`),
|
|
@@ -21,6 +21,7 @@ export interface ActivityMember {
|
|
|
21
21
|
readonly id: string;
|
|
22
22
|
readonly name: string;
|
|
23
23
|
readonly role: string;
|
|
24
|
+
readonly status?: 'idle' | 'working' | 'removed';
|
|
24
25
|
readonly activity: 'working' | 'idle' | 'unknown';
|
|
25
26
|
readonly progress: number;
|
|
26
27
|
readonly done: number;
|