@nanmicoder/dsh-agent-teams 0.1.17 → 0.1.18
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 +5 -3
- package/README_ZH.md +6 -2
- 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 +22 -0
- package/lib/index.js +4 -4
- package/lib/mailbox.js +154 -0
- package/lib/members.js +90 -18
- package/lib/quality-gates.js +21 -6
- package/lib/scheduler.js +27 -15
- package/lib/snapshot.js +1 -1
- package/lib/state.js +24 -5
- package/lib/tools.js +205 -213
- package/lib/types/harness-compat.d.ts +2 -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/scheduler.d.ts +1 -0
- package/lib/types/state.d.ts +5 -0
- package/lib/types/types.d.ts +9 -0
- package/package.json +2 -2
- package/release-notes/v0.1.18.md +21 -0
package/lib/harness-compat.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createUserMessage } from '@deepseek-ai/dsh-llm';
|
|
1
2
|
import { SubagentError } from '@deepseek-ai/dsh-subagent';
|
|
2
3
|
/**
|
|
3
4
|
* Exact protocol exported by dsh-subagent/internal in Alpha.5 and rc.1.
|
|
@@ -105,6 +106,27 @@ export async function queueMemberPrompt(runtime, parent, childId, content, signa
|
|
|
105
106
|
return unsupported('missing host FIFO delivery');
|
|
106
107
|
return queue.call(runtime, parent, childId, content, source, signal);
|
|
107
108
|
}
|
|
109
|
+
/** Coordination joins the nearest step, including waking an idle/cold child. */
|
|
110
|
+
export async function steerMemberPrompt(runtime, parent, childId, content, signal, live) {
|
|
111
|
+
const host = boundary(runtime);
|
|
112
|
+
const source = { kind: 'plugin', plugin: 'dsh-agent-teams' };
|
|
113
|
+
const deliver = host[hostPromptDeliver];
|
|
114
|
+
if (typeof deliver === 'function')
|
|
115
|
+
return deliver.call(runtime, parent, childId, content, source, signal, 'steer');
|
|
116
|
+
if (typeof host.sendMessage === 'function')
|
|
117
|
+
return host.sendMessage.call(runtime, parent, childId, content, { signal });
|
|
118
|
+
if (typeof host.followup === 'function') {
|
|
119
|
+
if (live === undefined)
|
|
120
|
+
return queueMemberPrompt(runtime, parent, childId, content, signal);
|
|
121
|
+
if (live.id !== childId || live.session.header.parentSession !== parent.id)
|
|
122
|
+
throw new Error('invalid member steering authority');
|
|
123
|
+
signal.throwIfAborted();
|
|
124
|
+
const message = createUserMessage({ content, source });
|
|
125
|
+
live.steer(message);
|
|
126
|
+
return message.id;
|
|
127
|
+
}
|
|
128
|
+
return unsupported('missing step-boundary message delivery');
|
|
129
|
+
}
|
|
108
130
|
/** Guard all resumable delivery paths, preserving the native service receiver. */
|
|
109
131
|
export function guardSubagentDelivery(ctx, isRetired) {
|
|
110
132
|
const runtime = ctx.subagents;
|
package/lib/index.js
CHANGED
|
@@ -80,7 +80,7 @@ export const Config = z.object({
|
|
|
80
80
|
dependencies: z.array(z.string()),
|
|
81
81
|
})),
|
|
82
82
|
})).default({}),
|
|
83
|
-
memberMaxDepth: z.natural().default(
|
|
83
|
+
memberMaxDepth: z.natural().default(0),
|
|
84
84
|
maxMembers: z.natural().min(1).default(8),
|
|
85
85
|
promptSectionOrder: z.natural().default(117),
|
|
86
86
|
slashCommand: z.boolean().default(true),
|
|
@@ -90,11 +90,11 @@ export function usageSectionText(toolNames, profilesText = '') {
|
|
|
90
90
|
return `AgentTeams captain protocol:
|
|
91
91
|
1. Inspect current team state when needed, using agent_teams_status. Continue existing work without duplicating its roster/tasks. Create only when no current team exists, with the user's goal as description and approval="required"; automatic approval requires an explicit request to run immediately. Staged plans never spawn or schedule work.
|
|
92
92
|
2. Add each needed role once; members inherit your model route unless another is requested/needed. A requested profile goes to create({profile}); it supplies its roster. Seed profiles also supply tasks; captain-planning profiles require your DAG. Do not duplicate either.
|
|
93
|
-
3. Build the complete smallest useful DAG while staged. Every task needs a subject;
|
|
93
|
+
3. Build the complete smallest useful DAG while staged. For an ordinary research/audit plan, pass the roster and dependency graph together in create({plan:{members,tasks}}) to avoid repeated setup rounds. Every task needs a subject; pass kind and assignee explicitly when the plan specifies them. Titles, descriptions and member roles do not set these fields. Dependencies represent prerequisites. Give every required contributor a task or explicit message. Present the plan and end your turn for review; never approve in that planning turn. Approve only after a later explicit user approval or the Web action.
|
|
94
94
|
4. Respect Web approve/return/discard control messages. On return, ask what to change before editing; after the answer, use one atomic agent_teams_edit_plan batch (edit downstream references before removals), summarize and await review again. Never inspect or edit .agent-teams state files or plugin source code to revise plans. Discard does not authorize a replacement.
|
|
95
95
|
5. The scheduler dispatches ready tasks after approval. Delegate; do not duplicate slow work or send messages merely to start a stage. Handle reports/user work, then yield when waiting is all that remains: reports wake you automatically. Use status after a delivery or user request, never busy-poll or wait for unassigned members.
|
|
96
96
|
6. Tasks carry attempt_id capabilities. Use the current attempt_id; stale means ownership changed. Pause members only on explicit request; later guidance via send_message continues that same attempt. Retry, transfer or take over through reassign_task first; it revokes the old attempt and waits for quiescence. Prefer a member. Captain implementation/review takeover requires a user request. Every takeover is one ready task at a time, finished in this turn; never yield with captain-owned work open.
|
|
97
|
-
7. Quality kinds (requirements, implementation, verification, review, repair, integration) require objective + acceptance; implementation/repair also require inScope + verify. Derive paths/commands from the workspace/profile, never assume src/ or pnpm test. Review/requirements complete only with verdict=pass; needs_revision/reject fail with findings. Never approve your own implementation or ask for a deliberate failure.
|
|
97
|
+
7. In a running team, correct never-started pending tasks with edit_plan update_task; preserve dependency and ownership contracts instead of cancelling and recreating the graph. A captain can cancel a never-started pending task directly. Active attempts still require reassign_task. Quality kinds (requirements, implementation, verification, review, repair, integration) require objective + acceptance; implementation/repair also require inScope + verify. Derive paths/commands from the workspace/profile, never assume src/ or pnpm test. Review/requirements complete only with verdict=pass; needs_revision/reject fail with findings. Never approve your own implementation or ask for a deliberate failure.
|
|
98
98
|
8. When full quality mode is requested: requirements → implementation → verification → review → integration. Plan the entire DAG while staged, including implementation before requirements finishes and integration depending on review round 1. Failed review automatically adds repair + next review and rewires pending downstream gates. Do not recreate this loop, omit integration or depend on a failed task. Review acceptance judges the latest implementation. Do not put smoke-test scripts into task instructions.
|
|
99
99
|
9. Halted means the user stopped work (including the captain turn). Resume only on a later explicit user request with a reason, via agent_teams_resume or create_task({resume:true,resumeReason}); creating tasks alone never resumes. Escalated means the review loop hit its limit, not a halt. Deployment requires explicit user confirmation.
|
|
100
100
|
10. Wait for all required tasks to be terminal and members idle/ready, present results, then delete/archive unless the user wants to continue. Never discard unfinished work without authorization.
|
|
@@ -107,7 +107,7 @@ export function apply(ctx, config) {
|
|
|
107
107
|
memberModel: config.memberModel,
|
|
108
108
|
executionPrompt: config.executionPrompt,
|
|
109
109
|
fallback: config.fallback,
|
|
110
|
-
memberMaxDepth: config.memberMaxDepth ??
|
|
110
|
+
memberMaxDepth: config.memberMaxDepth ?? 0,
|
|
111
111
|
maxMembers: config.maxMembers ?? 8,
|
|
112
112
|
profiles: config.profiles ?? {},
|
|
113
113
|
};
|
package/lib/mailbox.js
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { acknowledgeMailbox, CAPTAIN_KEY, discardMailboxMessages, findTeamByCaptain, readMailbox, readTeam, sanitizeKey, withTeamLock } from "./state.js";
|
|
3
|
+
import { sessionOwnEvents } from "./harness-compat.js";
|
|
4
|
+
const PREFIX = 'AgentTeams inbox receipt: ';
|
|
5
|
+
export function isCurrentMail(team, message) {
|
|
6
|
+
if (message.discardedAt !== undefined)
|
|
7
|
+
return false;
|
|
8
|
+
if (message.attemptId === undefined)
|
|
9
|
+
return true;
|
|
10
|
+
return team.tasks.some(task => task.id === message.taskId && task.attemptId === message.attemptId
|
|
11
|
+
&& task.assignee === message.to && (task.status === 'claimed' || task.status === 'in_progress'));
|
|
12
|
+
}
|
|
13
|
+
function mailboxBody(recipient, messages) {
|
|
14
|
+
return messages.map(message => recipient === CAPTAIN_KEY
|
|
15
|
+
? `AgentTeams message from member ${message.from}:\n\n${message.content}`
|
|
16
|
+
: `AgentTeams message from ${message.from}${message.attemptId === undefined ? '' : ` for task ${message.taskId}, attempt_id ${message.attemptId}`}:\n\n${message.content}`).join('\n\n');
|
|
17
|
+
}
|
|
18
|
+
export function mailboxPrompt(teamId, recipient, messages) {
|
|
19
|
+
return PREFIX + JSON.stringify({ teamId, recipient, ids: messages.map(message => message.id) }) + '\n\n' + mailboxBody(recipient, messages);
|
|
20
|
+
}
|
|
21
|
+
function parseReceipt(text) {
|
|
22
|
+
if (!text.startsWith(PREFIX))
|
|
23
|
+
return undefined;
|
|
24
|
+
try {
|
|
25
|
+
const value = JSON.parse(text.slice(PREFIX.length).split('\n')[0]);
|
|
26
|
+
if (typeof value !== 'object' || value === null)
|
|
27
|
+
return undefined;
|
|
28
|
+
const raw = value;
|
|
29
|
+
if (typeof raw['teamId'] !== 'string' || typeof raw['recipient'] !== 'string'
|
|
30
|
+
|| !Array.isArray(raw['ids']) || !raw['ids'].every(id => typeof id === 'string'))
|
|
31
|
+
return undefined;
|
|
32
|
+
if (raw['teamId'] !== sanitizeKey(raw['teamId']))
|
|
33
|
+
return undefined;
|
|
34
|
+
return { teamId: raw['teamId'], recipient: raw['recipient'], ids: raw['ids'] };
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/** Only messages actually admitted to this recipient's step become read. */
|
|
41
|
+
export function installMailboxAdmission(ctx, stateDir) {
|
|
42
|
+
ctx.on('agent/pre-step', async (payload, next) => {
|
|
43
|
+
const decision = await next();
|
|
44
|
+
if (decision.kind !== 'enter')
|
|
45
|
+
return decision;
|
|
46
|
+
const root = join(payload.agent.session.header.cwd ?? process.cwd(), stateDir);
|
|
47
|
+
// The native runtime also wakes a parent when a background activation
|
|
48
|
+
// settles. A successfully delivered Team report already carries that
|
|
49
|
+
// result. Keep abnormal endings and missing reports as fallback signals.
|
|
50
|
+
const successfulSettlements = new Set(decision.messages.flatMap(input => {
|
|
51
|
+
const source = input.source;
|
|
52
|
+
return source?.kind === 'subagent-settled'
|
|
53
|
+
&& source.summary === `Background subagent ${source.senderSessionId} finished and will do no further work unless you send it more.`
|
|
54
|
+
? [source.senderSessionId] : [];
|
|
55
|
+
}));
|
|
56
|
+
const redundantSettlements = new Set();
|
|
57
|
+
if (successfulSettlements.size > 0) {
|
|
58
|
+
const owned = await findTeamByCaptain(root, payload.agent.id);
|
|
59
|
+
if (owned !== undefined)
|
|
60
|
+
await withTeamLock(`team:${root}:${owned.id}`, async () => {
|
|
61
|
+
const team = await readTeam(root, owned.id);
|
|
62
|
+
if (team?.captainSessionId !== payload.agent.id || team.halted)
|
|
63
|
+
return;
|
|
64
|
+
const reports = await readMailbox(root, team.id, CAPTAIN_KEY);
|
|
65
|
+
for (const member of team.members) {
|
|
66
|
+
if (!successfulSettlements.has(member.id) || member.status === 'removed' || member.stopping)
|
|
67
|
+
continue;
|
|
68
|
+
const tasks = team.tasks.filter(task => task.assignee === member.name);
|
|
69
|
+
if (tasks.some(task => task.status === 'claimed' || task.status === 'in_progress'))
|
|
70
|
+
continue;
|
|
71
|
+
const latest = tasks.filter(task => (task.attempt ?? 0) > 0 || task.status === 'completed').sort((a, b) => b.updatedAt - a.updatedAt)[0];
|
|
72
|
+
if (latest?.status === 'completed' && reports.some(report => report.from === member.name && report.ts >= latest.updatedAt
|
|
73
|
+
&& report.discardedAt === undefined && (report.deliveredAt !== undefined || report.readAt !== undefined))) {
|
|
74
|
+
redundantSettlements.add(member.id);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
const batches = new Map();
|
|
80
|
+
const inputs = decision.messages.map(input => {
|
|
81
|
+
// Alpha.5/0.1.2-rc.1 public steering prepends a native sender block.
|
|
82
|
+
// The host-authored receipt remains a separate text block after it.
|
|
83
|
+
const receipt = input.content.flatMap(block => block.type === 'text' ? [parseReceipt(block.text)] : []).find(value => value !== undefined);
|
|
84
|
+
if (receipt === undefined)
|
|
85
|
+
return { input };
|
|
86
|
+
const key = JSON.stringify([receipt.teamId, receipt.recipient]);
|
|
87
|
+
let batch = batches.get(key);
|
|
88
|
+
if (batch === undefined) {
|
|
89
|
+
batch = { receipt, ids: new Set(), current: new Map() };
|
|
90
|
+
batches.set(key, batch);
|
|
91
|
+
}
|
|
92
|
+
for (const id of receipt.ids)
|
|
93
|
+
batch.ids.add(id);
|
|
94
|
+
return { input, receipt, batch };
|
|
95
|
+
});
|
|
96
|
+
// One read/acknowledgement per recipient per step, even for a large burst.
|
|
97
|
+
for (const batch of batches.values()) {
|
|
98
|
+
const { receipt } = batch;
|
|
99
|
+
await withTeamLock(`team:${root}:${receipt.teamId}`, async () => {
|
|
100
|
+
const team = await readTeam(root, receipt.teamId);
|
|
101
|
+
if (team === undefined)
|
|
102
|
+
return;
|
|
103
|
+
const recipientId = receipt.recipient === CAPTAIN_KEY ? team.captainSessionId
|
|
104
|
+
: team.members.find(member => member.name === receipt.recipient && member.status !== 'removed' && member.stopping !== true)?.id;
|
|
105
|
+
if (recipientId !== payload.agent.id)
|
|
106
|
+
return;
|
|
107
|
+
const selected = (await readMailbox(root, team.id, receipt.recipient)).filter(message => batch.ids.has(message.id));
|
|
108
|
+
const current = selected.filter(message => message.readAt === undefined && isCurrentMail(team, message));
|
|
109
|
+
await discardMailboxMessages(root, team.id, receipt.recipient, selected.filter(message => !isCurrentMail(team, message)).map(message => message.id));
|
|
110
|
+
await acknowledgeMailbox(root, team.id, receipt.recipient, current.map(message => message.id));
|
|
111
|
+
batch.current = new Map(current.map(message => [message.id, message]));
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
const admitted = [];
|
|
115
|
+
for (const { input, receipt, batch } of inputs) {
|
|
116
|
+
if (input.source?.kind === 'subagent-settled' && redundantSettlements.has(input.source.senderSessionId)
|
|
117
|
+
&& input.source.summary === `Background subagent ${input.source.senderSessionId} finished and will do no further work unless you send it more.`)
|
|
118
|
+
continue;
|
|
119
|
+
if (receipt === undefined || batch === undefined) {
|
|
120
|
+
admitted.push(input);
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
const messages = [];
|
|
124
|
+
for (const id of receipt.ids) {
|
|
125
|
+
const message = batch.current.get(id);
|
|
126
|
+
if (message === undefined)
|
|
127
|
+
continue;
|
|
128
|
+
batch.current.delete(id);
|
|
129
|
+
messages.push(message);
|
|
130
|
+
}
|
|
131
|
+
if (messages.length > 0)
|
|
132
|
+
admitted.push({ ...input, content: [{ type: 'text', text: mailboxBody(receipt.recipient, messages) }] });
|
|
133
|
+
}
|
|
134
|
+
// Reject only a new turn whose sole trigger was obsolete mail. During an
|
|
135
|
+
// existing turn, a status tool may have consumed this same mail already;
|
|
136
|
+
// dropping its duplicate must not block the pending tool-result response.
|
|
137
|
+
if (decision.messages.length > 0 && admitted.length === 0) {
|
|
138
|
+
const events = sessionOwnEvents(payload.agent.session);
|
|
139
|
+
let continuing = false;
|
|
140
|
+
for (let i = events.length - 1; i >= 0; i--) {
|
|
141
|
+
const event = events[i];
|
|
142
|
+
if (event.type === 'step/start' && event.data.turn === payload.turn) {
|
|
143
|
+
continuing = true;
|
|
144
|
+
break;
|
|
145
|
+
}
|
|
146
|
+
if (event.type === 'turn/start')
|
|
147
|
+
break;
|
|
148
|
+
}
|
|
149
|
+
if (!continuing)
|
|
150
|
+
return { kind: 'reject' };
|
|
151
|
+
}
|
|
152
|
+
return { ...decision, messages: admitted };
|
|
153
|
+
});
|
|
154
|
+
}
|
package/lib/members.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Member subagent lifecycle: spawn a continuable child per member, deliver
|
|
3
|
-
*
|
|
3
|
+
* task turns and step-boundary messages, and observe its activity.
|
|
4
4
|
*
|
|
5
5
|
* Members are durable continuable subagents of the captain, so a member keeps
|
|
6
6
|
* its conversation across turns and across harness restarts: the captain
|
|
@@ -16,8 +16,9 @@ import { installModelSelection } from '@deepseek-ai/dsh-agent';
|
|
|
16
16
|
import { foldSubagentDescriptor } from '@deepseek-ai/dsh-subagent';
|
|
17
17
|
import { createUserMessage, LlmError, ReasoningEffortId } from '@deepseek-ai/dsh-llm';
|
|
18
18
|
import { join } from 'node:path';
|
|
19
|
-
import { guardSubagentDelivery, installContinuableMemberSetup, queueMemberPrompt, sessionOwnEvents } from "./harness-compat.js";
|
|
20
|
-
import {
|
|
19
|
+
import { guardSubagentDelivery, installContinuableMemberSetup, queueMemberPrompt, steerMemberPrompt, sessionOwnEvents } from "./harness-compat.js";
|
|
20
|
+
import { markMailboxDelivered, appendMailbox, CAPTAIN_KEY, createMessage, readRetiredMemberIds, readTeamSync, readTeam, releaseMailboxDelivery, withTeamLock, writeTeam } from "./state.js";
|
|
21
|
+
import { mailboxPrompt } from "./mailbox.js";
|
|
21
22
|
import { appendTeamEvent, captainSessionOf } from "./events.js";
|
|
22
23
|
import { TERMINAL_TASK_STATUSES } from "./types.js";
|
|
23
24
|
import { CAPTAIN_TOOL_NAMES } from "./tool-names.js";
|
|
@@ -70,10 +71,10 @@ export function selectFallbackRoute(current, fallback, failureCode, alreadySwitc
|
|
|
70
71
|
return { retry: true, switched: true, selection: fallback };
|
|
71
72
|
}
|
|
72
73
|
/** Deliver a durable member report to the live captain at its next model step. */
|
|
73
|
-
export function steerCaptainReport(captain, from, content) {
|
|
74
|
+
export function steerCaptainReport(captain, from, content, receipt) {
|
|
74
75
|
try {
|
|
75
76
|
captain.steer(createUserMessage({
|
|
76
|
-
content: [{ type: 'text', text: `AgentTeams message from member ${from}:\n\n${content}` }],
|
|
77
|
+
content: [{ type: 'text', text: receipt ?? `AgentTeams message from member ${from}:\n\n${content}` }],
|
|
77
78
|
source: { kind: 'plugin', plugin: 'dsh-agent-teams' },
|
|
78
79
|
}));
|
|
79
80
|
return true;
|
|
@@ -138,9 +139,9 @@ export async function failMemberOpenAttempt(ctx, stateRoot, teamId, memberName,
|
|
|
138
139
|
// Use the same lease/acknowledgment contract as send_message, outside the
|
|
139
140
|
// team lock: steering can synchronously start another agent turn.
|
|
140
141
|
const captain = ctx.agents.get(brandedSessionId(prepared.captainSessionId));
|
|
141
|
-
const delivered = captain !== undefined && steerCaptainReport(captain, memberName, prepared.message.content);
|
|
142
|
+
const delivered = captain !== undefined && steerCaptainReport(captain, memberName, prepared.message.content, mailboxPrompt(teamId, CAPTAIN_KEY, [prepared.message]));
|
|
142
143
|
await withTeamLock(lockKey, () => delivered
|
|
143
|
-
?
|
|
144
|
+
? markMailboxDelivered(stateRoot, teamId, CAPTAIN_KEY, [prepared.message.id])
|
|
144
145
|
: releaseMailboxDelivery(stateRoot, teamId, CAPTAIN_KEY, [prepared.message.id]));
|
|
145
146
|
return true;
|
|
146
147
|
}
|
|
@@ -280,11 +281,26 @@ export function installMemberSelectionRuntime(ctx, stateDir, onFailureSettled) {
|
|
|
280
281
|
const workspace = child.session.header.cwd ?? process.cwd();
|
|
281
282
|
const stateRoot = join(workspace, stateDir);
|
|
282
283
|
const key = pendingSelectionKey(parentSessionId, descriptor.label);
|
|
284
|
+
// Internal settlement wakeups bypass the public delivery guard. Recheck
|
|
285
|
+
// exact durable membership on every proposed step, including cold resume.
|
|
286
|
+
const disposeAdmission = childCtx.on('agent/pre-step', async (payload, next) => {
|
|
287
|
+
if (payload.agent.id !== child.id)
|
|
288
|
+
return next();
|
|
289
|
+
const admitted = await withTeamLock(`team:${stateRoot}:${teamId}`, async () => {
|
|
290
|
+
const team = await readTeam(stateRoot, teamId);
|
|
291
|
+
return team?.captainSessionId === parentSessionId && team.phase !== 'staged' && team.halted !== true
|
|
292
|
+
&& team.members.some(member => member.id === child.id && member.name === memberName && member.status !== 'removed' && member.stopping !== true)
|
|
293
|
+
&& !team.tasks.some(task => task.reassigning === true && task.assignee === memberName);
|
|
294
|
+
});
|
|
295
|
+
if (!admitted)
|
|
296
|
+
return { kind: 'reject' };
|
|
297
|
+
return next();
|
|
298
|
+
});
|
|
283
299
|
let selection = pending.get(key);
|
|
284
300
|
if (selection === undefined) {
|
|
285
301
|
const team = readTeamSync(stateRoot, teamId);
|
|
286
302
|
if (team?.captainSessionId !== parentSessionId)
|
|
287
|
-
return
|
|
303
|
+
return disposeAdmission;
|
|
288
304
|
const durableMember = team.members.find(member => member.name === memberName);
|
|
289
305
|
selection = selectionFromMember(durableMember);
|
|
290
306
|
if (selection !== undefined && (descriptor.agentProvider !== durableMember?.provider || descriptor.agentModel !== durableMember?.model)) {
|
|
@@ -341,7 +357,7 @@ export function installMemberSelectionRuntime(ctx, stateDir, onFailureSettled) {
|
|
|
341
357
|
});
|
|
342
358
|
// Legacy teams still need failure reporting even without a saved route.
|
|
343
359
|
if (selection === undefined)
|
|
344
|
-
return disposeFailure;
|
|
360
|
+
return () => { disposeFailure(); disposeAdmission(); };
|
|
345
361
|
const selectionRef = { current: modelSelection(selection), assembled: undefined };
|
|
346
362
|
const disposeSelection = installModelSelection(childCtx, selectionRef);
|
|
347
363
|
const fallback = selection.fallback;
|
|
@@ -371,6 +387,7 @@ export function installMemberSelectionRuntime(ctx, stateDir, onFailureSettled) {
|
|
|
371
387
|
disposeFallback();
|
|
372
388
|
disposeSelection();
|
|
373
389
|
disposeFailure();
|
|
390
|
+
disposeAdmission();
|
|
374
391
|
};
|
|
375
392
|
});
|
|
376
393
|
return {
|
|
@@ -432,7 +449,7 @@ Team context:
|
|
|
432
449
|
${injectedPrompt === undefined || injectedPrompt === '' ? '' : `- Execution guidance:
|
|
433
450
|
${injectedPrompt}
|
|
434
451
|
`}- The team state lives under ${stateDir}/${team.id}/ (team.json and inbox/*.jsonl). You may inspect these files read-only for diagnostics, but never edit them directly; use the agent_teams_* tools so JSON escaping and concurrent updates stay safe.
|
|
435
|
-
- The captain and your teammates reach you through messages.
|
|
452
|
+
- The captain and your teammates reach you through messages. Coordination arrives at your nearest model step. Apply it to your current attempt; a task assignment starts a separate turn.
|
|
436
453
|
When you receive a task, treat the assignment prompt's dependency results as source material. Do not ignore them.
|
|
437
454
|
|
|
438
455
|
Working rules:
|
|
@@ -444,8 +461,8 @@ Working rules:
|
|
|
444
461
|
- include a concise output in either case;
|
|
445
462
|
- a stale-attempt rejection means the captain reassigned or took over the task; stop touching that task and wait for new work.
|
|
446
463
|
claimed cannot jump to completed. Mark in_progress first, then completed or failed.
|
|
447
|
-
Include attempt_id on every update. Then
|
|
448
|
-
4. Send
|
|
464
|
+
Include attempt_id on every update. Then report once as described below and become idle.
|
|
465
|
+
4. Send one short report with agent_teams_send_message (to=captain) when you complete a task or hit a blocker. The captain is also your parent: this single message satisfies both reporting duties. Do not repeat it through the native send_message tool or send acknowledgments that add no new information.
|
|
449
466
|
5. To ask a teammate something, use agent_teams_send_message with to=<teammate name>; the message lands in their mailbox and wakes them directly — teammates talk to each other without the captain in the loop. The same applies to the captain (to=captain).
|
|
450
467
|
6. After your turn becomes idle, the shared task scheduler may assign your next ready task automatically. Never claim a second task while you still own unfinished work.
|
|
451
468
|
7. If you already own an open attempt (claimed or in_progress) and receive mail, treat it as guidance for that same attempt_id unless the mail explicitly tells you to stop or fail. Do not claim a new task in that turn.
|
|
@@ -478,7 +495,7 @@ Do not start work until the scheduler or captain assigns a task in this turn.`;
|
|
|
478
495
|
* @param stateDir - configured state directory (for the persona).
|
|
479
496
|
* @param signal - caller cancellation, forwarded to the start.
|
|
480
497
|
*/
|
|
481
|
-
export async function spawnMember(ctx, config, selections, llmSelection, captain, team, member, stateDir, signal) {
|
|
498
|
+
export async function spawnMember(ctx, config, selections, llmSelection, captain, team, member, stateDir, signal, initialPrompt) {
|
|
482
499
|
// Fail loud at the first use: provider registration is a sibling plugin's
|
|
483
500
|
// effect and may settle after this plugin mounts. Capability checks here
|
|
484
501
|
// mirror what startContinuable would reject, with an actionable error.
|
|
@@ -501,10 +518,10 @@ export async function spawnMember(ctx, config, selections, llmSelection, captain
|
|
|
501
518
|
provider: config.provider,
|
|
502
519
|
label,
|
|
503
520
|
request: {
|
|
504
|
-
prompt: [{ type: 'text', text: memberWelcome(team, member.name) }],
|
|
521
|
+
prompt: [{ type: 'text', text: initialPrompt ?? memberWelcome(team, member.name) }],
|
|
505
522
|
parent: captain,
|
|
506
523
|
persona: memberPersona(team, member, stateDir, config.executionPrompt),
|
|
507
|
-
toolFilter: { deny: [...MEMBER_DENIED_TOOLS] },
|
|
524
|
+
toolFilter: { deny: [...MEMBER_DENIED_TOOLS, ...(config.maxDepth === 0 ? ['subagent', 'send_message'] : [])] },
|
|
508
525
|
agentOptions: {
|
|
509
526
|
provider: llmSelection.provider,
|
|
510
527
|
model: llmSelection.model,
|
|
@@ -512,7 +529,9 @@ export async function spawnMember(ctx, config, selections, llmSelection, captain
|
|
|
512
529
|
? {}
|
|
513
530
|
: { reasoningEffort: ReasoningEffortId(llmSelection.reasoningEffort) },
|
|
514
531
|
},
|
|
515
|
-
|
|
532
|
+
// Harness request.maxDepth caps the absolute depth of THIS creation;
|
|
533
|
+
// it is not inherited by later delegations. The guard below enforces
|
|
534
|
+
// the configured member-relative descendant budget instead.
|
|
516
535
|
},
|
|
517
536
|
signal,
|
|
518
537
|
})));
|
|
@@ -535,9 +554,13 @@ export async function spawnMember(ctx, config, selections, llmSelection, captain
|
|
|
535
554
|
* @param signal - caller cancellation, forwarded to the delivery.
|
|
536
555
|
* @returns whether the member inbox accepted the message.
|
|
537
556
|
*/
|
|
538
|
-
export async function deliverToMember(ctx, captain, childId, text, signal) {
|
|
557
|
+
export async function deliverToMember(ctx, captain, childId, text, signal, mode = 'queue') {
|
|
539
558
|
try {
|
|
540
|
-
|
|
559
|
+
const content = [{ type: 'text', text }];
|
|
560
|
+
if (mode === 'steer')
|
|
561
|
+
await steerMemberPrompt(ctx.subagents, captain, brandedSessionId(childId), content, signal, ctx.agents.get(brandedSessionId(childId)));
|
|
562
|
+
else
|
|
563
|
+
await queueMemberPrompt(ctx.subagents, captain, brandedSessionId(childId), content, signal);
|
|
541
564
|
return true;
|
|
542
565
|
}
|
|
543
566
|
catch (error) {
|
|
@@ -578,6 +601,55 @@ export function installRetiredMemberGuard(ctx, stateDir) {
|
|
|
578
601
|
return retired.has(childId);
|
|
579
602
|
});
|
|
580
603
|
}
|
|
604
|
+
/** Bound all descendant creation, including renamed tools and code-runtime calls. */
|
|
605
|
+
export function installMemberDelegationGuard(ctx, stateDir, maxDepth) {
|
|
606
|
+
const runtime = ctx.subagents;
|
|
607
|
+
let active = true;
|
|
608
|
+
const check = (parent) => {
|
|
609
|
+
if (!active)
|
|
610
|
+
return;
|
|
611
|
+
let ancestor = parent;
|
|
612
|
+
let depth = 1;
|
|
613
|
+
while (ancestor !== undefined) {
|
|
614
|
+
const descriptor = foldSubagentDescriptor(sessionOwnEvents(ancestor.session));
|
|
615
|
+
if (descriptor?.label?.startsWith(MEMBER_LABEL_PREFIX)) {
|
|
616
|
+
const identity = descriptor.label.slice(MEMBER_LABEL_PREFIX.length);
|
|
617
|
+
const separator = identity.indexOf(':');
|
|
618
|
+
const team = readTeamSync(join(ancestor.session.header.cwd ?? process.cwd(), stateDir), identity.slice(0, separator));
|
|
619
|
+
const member = team?.members.find(item => item.id === ancestor.id && item.name === identity.slice(separator + 1));
|
|
620
|
+
if (member === undefined || member.status === 'removed' || member.stopping === true || team?.halted === true)
|
|
621
|
+
throw new Error('AgentTeams member is no longer admitting delegated work');
|
|
622
|
+
if (depth > maxDepth)
|
|
623
|
+
throw new Error(`AgentTeams member delegation limit (${maxDepth}) reached; report to the captain instead of spawning another agent`);
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
626
|
+
const parentId = ancestor.session.header.parentSession;
|
|
627
|
+
ancestor = parentId === undefined ? undefined : ctx.agents.get(parentId);
|
|
628
|
+
depth++;
|
|
629
|
+
}
|
|
630
|
+
};
|
|
631
|
+
ctx.effect(() => {
|
|
632
|
+
const start = runtime.start;
|
|
633
|
+
const continuable = runtime.startContinuable;
|
|
634
|
+
const startDescriptor = Object.getOwnPropertyDescriptor(runtime, 'start');
|
|
635
|
+
const continuableDescriptor = Object.getOwnPropertyDescriptor(runtime, 'startContinuable');
|
|
636
|
+
const guardedStart = async (name, request) => { check(request.parent); return start.call(runtime, name, request); };
|
|
637
|
+
const guardedContinuable = async (spec) => { check(spec.request.parent); return continuable.call(runtime, spec); };
|
|
638
|
+
runtime.start = guardedStart;
|
|
639
|
+
runtime.startContinuable = guardedContinuable;
|
|
640
|
+
return () => {
|
|
641
|
+
active = false;
|
|
642
|
+
for (const [key, fn, descriptor] of [['start', guardedStart, startDescriptor], ['startContinuable', guardedContinuable, continuableDescriptor]]) {
|
|
643
|
+
if (Object.getOwnPropertyDescriptor(runtime, key)?.value !== fn)
|
|
644
|
+
continue;
|
|
645
|
+
if (descriptor === undefined)
|
|
646
|
+
Reflect.deleteProperty(runtime, key);
|
|
647
|
+
else
|
|
648
|
+
Object.defineProperty(runtime, key, descriptor);
|
|
649
|
+
}
|
|
650
|
+
};
|
|
651
|
+
}, 'agent-teams: member delegation budget');
|
|
652
|
+
}
|
|
581
653
|
/**
|
|
582
654
|
* Snapshot the real driver activity for durable member ids.
|
|
583
655
|
*
|
package/lib/quality-gates.js
CHANGED
|
@@ -269,13 +269,14 @@ export function validateCreateTask(team, input) {
|
|
|
269
269
|
if (kind === 'implementation') {
|
|
270
270
|
const requirements = team.tasks.filter((item) => taskKindOf(item) === 'requirements');
|
|
271
271
|
const passed = requirements.some((item) => item.status === 'completed' && item.verdict === 'pass');
|
|
272
|
-
const
|
|
273
|
-
|
|
272
|
+
const behindRequirements = requirements.some((item) => (dependencyClosureContains(team.tasks, dependencies, item.id)));
|
|
273
|
+
// Planning an implementation is safe in either approval mode when its
|
|
274
|
+
// dependency chain fences execution behind requirements. Scheduling and
|
|
275
|
+
// claiming still wait for successful dependency completion.
|
|
276
|
+
if (requirements.length > 0 && !passed && !behindRequirements) {
|
|
274
277
|
return {
|
|
275
278
|
ok: false,
|
|
276
|
-
error:
|
|
277
|
-
? 'implementation must depend on the staged requirements task; it will run only after requirements passes'
|
|
278
|
-
: 'implementation is blocked until a requirements task completes with verdict=pass',
|
|
279
|
+
error: 'implementation must depend on a requirements task until requirements completes with verdict=pass',
|
|
279
280
|
};
|
|
280
281
|
}
|
|
281
282
|
}
|
|
@@ -515,6 +516,20 @@ export function buildCoverageMatrix(goalItems, tasks) {
|
|
|
515
516
|
}
|
|
516
517
|
export function canDeclareDelivery(team) {
|
|
517
518
|
const blockers = [];
|
|
519
|
+
if (team.phase === 'staged')
|
|
520
|
+
blockers.push('team plan is awaiting approval');
|
|
521
|
+
if (team.halted === true)
|
|
522
|
+
blockers.push('team is halted');
|
|
523
|
+
if (team.escalated === true)
|
|
524
|
+
blockers.push('team requires escalation resolution');
|
|
525
|
+
if (team.tasks.length === 0)
|
|
526
|
+
blockers.push('team has no completed work');
|
|
527
|
+
for (const item of team.tasks.filter(item => !isQualityKind(taskKindOf(item)))) {
|
|
528
|
+
if (item.status !== 'completed' && item.status !== 'cancelled')
|
|
529
|
+
blockers.push(`${item.id} (${taskKindOf(item)}) is not completed`);
|
|
530
|
+
}
|
|
531
|
+
if (team.tasks.length > 0 && team.tasks.every(item => item.status === 'cancelled'))
|
|
532
|
+
blockers.push('all work was cancelled');
|
|
518
533
|
const quality = team.tasks.filter((item) => isQualityKind(taskKindOf(item)));
|
|
519
534
|
const implementations = quality.filter((item) => taskKindOf(item) === 'implementation' || taskKindOf(item) === 'repair');
|
|
520
535
|
const reviews = quality.filter((item) => taskKindOf(item) === 'review');
|
|
@@ -796,7 +811,7 @@ export function describeQualityLoop(team) {
|
|
|
796
811
|
halted: false,
|
|
797
812
|
escalated: team.escalated === true,
|
|
798
813
|
deliverable: true,
|
|
799
|
-
summary: 'All required quality gates passed. The captain may report delivery.',
|
|
814
|
+
summary: 'All required work and quality gates passed. The captain may report delivery.',
|
|
800
815
|
};
|
|
801
816
|
}
|
|
802
817
|
if (team.escalated === true) {
|
package/lib/scheduler.js
CHANGED
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import { join } from 'node:path';
|
|
15
15
|
import { deliverToMember } from "./members.js";
|
|
16
|
-
import {
|
|
16
|
+
import { isCurrentMail, mailboxPrompt } from "./mailbox.js";
|
|
17
|
+
import { markMailboxDelivered, discardMailboxMessages, beginTaskAttempt, CAPTAIN_KEY, claimMailboxDelivery, findTeamByParticipant, invalidateTaskAttempt, readTeam, readPendingMailbox, releaseMailboxDelivery, unsatisfiedDependencies, withTeamLock, writeTeam, } from "./state.js";
|
|
17
18
|
/** Per-dependency output cap in the assignment prompt. */
|
|
18
19
|
export const DEPENDENCY_OUTPUT_MAX_CHARS = 2_000;
|
|
19
20
|
/** Combined dependency-output budget in the assignment prompt. */
|
|
@@ -103,6 +104,8 @@ function liveMember(ctx, member) {
|
|
|
103
104
|
return ctx.agents.get(member.id);
|
|
104
105
|
}
|
|
105
106
|
function isMemberAvailable(ctx, member) {
|
|
107
|
+
if (member.stopping === true)
|
|
108
|
+
return false;
|
|
106
109
|
const live = liveMember(ctx, member);
|
|
107
110
|
return live === undefined || live.status === 'idle';
|
|
108
111
|
}
|
|
@@ -168,13 +171,6 @@ When finishing: use status=completed only when the task's success criteria are s
|
|
|
168
171
|
|
|
169
172
|
State policy: ${stateDir}/${teamId}/ is read-only diagnostics; mutate team state only through agent_teams_* tools.`;
|
|
170
173
|
}
|
|
171
|
-
function fallbackMailboxPrompt(messages) {
|
|
172
|
-
return [
|
|
173
|
-
'AgentTeams delivered messages that were persisted while live delivery was unavailable:',
|
|
174
|
-
...messages.map(message => `\nFrom ${message.from}:\n${message.content}`),
|
|
175
|
-
'\nHandle these messages in this turn. Task assignments still require agent_teams_claim_task and the current attempt_id.',
|
|
176
|
-
].join('\n');
|
|
177
|
-
}
|
|
178
174
|
/** Install one scheduler and its member activity observer. */
|
|
179
175
|
export function installTeamScheduler(ctx, config) {
|
|
180
176
|
const memberQueues = new Map();
|
|
@@ -227,16 +223,28 @@ export function installTeamScheduler(ctx, config) {
|
|
|
227
223
|
if (captain === undefined)
|
|
228
224
|
return;
|
|
229
225
|
let member = team.members.find(candidate => candidate.name === memberName && candidate.status !== 'removed');
|
|
230
|
-
if (member === undefined ||
|
|
226
|
+
if (member === undefined || !isMemberAvailable(ctx, member))
|
|
231
227
|
return;
|
|
232
228
|
// A mailbox-only fallback is real pending work. Deliver it before a
|
|
233
229
|
// fresh task and acknowledge only after Harness accepts the follow-up.
|
|
234
|
-
const unread = await
|
|
230
|
+
const unread = await withTeamLock(teamLockKey(stateRoot, team.id), async () => {
|
|
231
|
+
const fresh = await readTeam(stateRoot, team.id);
|
|
232
|
+
if (fresh === undefined)
|
|
233
|
+
return [];
|
|
234
|
+
const pending = await readPendingMailbox(stateRoot, fresh.id, member.name);
|
|
235
|
+
await discardMailboxMessages(stateRoot, fresh.id, member.name, pending.filter(message => !isCurrentMail(fresh, message)).map(message => message.id));
|
|
236
|
+
const current = pending.filter(message => isCurrentMail(fresh, message));
|
|
237
|
+
await claimMailboxDelivery(stateRoot, fresh.id, member.name, current.map(message => message.id));
|
|
238
|
+
return current;
|
|
239
|
+
});
|
|
235
240
|
if (unread.length > 0) {
|
|
236
|
-
|
|
237
|
-
const
|
|
241
|
+
const prompt = mailboxPrompt(team.id, member.name, unread);
|
|
242
|
+
const signal = new AbortController().signal;
|
|
243
|
+
const accepted = config.dispatch === undefined
|
|
244
|
+
? await deliverToMember(ctx, captain, member.id, prompt, signal, 'steer')
|
|
245
|
+
: await config.dispatch(captain, team.id, member.name, prompt, signal, 'steer');
|
|
238
246
|
if (accepted) {
|
|
239
|
-
await withTeamLock(teamLockKey(stateRoot, team.id), () => (
|
|
247
|
+
await withTeamLock(teamLockKey(stateRoot, team.id), () => (markMailboxDelivered(stateRoot, team.id, member.name, unread.map(message => message.id))));
|
|
240
248
|
}
|
|
241
249
|
else {
|
|
242
250
|
await withTeamLock(teamLockKey(stateRoot, team.id), () => (releaseMailboxDelivery(stateRoot, team.id, member.name, unread.map(message => message.id))));
|
|
@@ -248,7 +256,7 @@ export function installTeamScheduler(ctx, config) {
|
|
|
248
256
|
if (fresh === undefined || fresh.halted === true || fresh.phase === 'staged')
|
|
249
257
|
return undefined;
|
|
250
258
|
const currentMember = fresh.members.find(candidate => candidate.name === memberName && candidate.status !== 'removed');
|
|
251
|
-
if (currentMember === undefined ||
|
|
259
|
+
if (currentMember === undefined || !isMemberAvailable(ctx, currentMember))
|
|
252
260
|
return undefined;
|
|
253
261
|
const owned = ownedOpenTask(fresh.tasks, currentMember.name);
|
|
254
262
|
// An idle edge observed by this scheduler parks the exact open
|
|
@@ -318,7 +326,11 @@ export function installTeamScheduler(ctx, config) {
|
|
|
318
326
|
});
|
|
319
327
|
if (ticket === undefined)
|
|
320
328
|
return;
|
|
321
|
-
const
|
|
329
|
+
const prompt = assignmentPrompt(ticket, config.stateDir, team.id);
|
|
330
|
+
const signal = new AbortController().signal;
|
|
331
|
+
const accepted = config.dispatch === undefined
|
|
332
|
+
? await deliverToMember(ctx, captain, ticket.memberId, prompt, signal)
|
|
333
|
+
: await config.dispatch(captain, team.id, ticket.memberName, prompt, signal, 'queue', ticket.attemptId);
|
|
322
334
|
if (accepted)
|
|
323
335
|
return;
|
|
324
336
|
// Roll back only our exact failed dispatch. A concurrent captain
|
package/lib/snapshot.js
CHANGED
|
@@ -76,7 +76,7 @@ export async function assembleTeamSnapshot(ctx, stateRoot, workspace, state, opt
|
|
|
76
76
|
: activity.get(member.id) === 'idle' || activity.get(member.id) === 'ready'
|
|
77
77
|
? 'idle'
|
|
78
78
|
: 'unknown')
|
|
79
|
-
:
|
|
79
|
+
: 'idle',
|
|
80
80
|
progress: owned.length === 0 ? 0 : Math.round((done / owned.length) * 100),
|
|
81
81
|
done,
|
|
82
82
|
total: owned.length,
|