@nanmicoder/dsh-agent-teams 0.1.15 → 0.1.16-rc.1
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 +48 -31
- package/README_ZH.md +49 -32
- package/compatibility.json +10 -0
- package/lib/client/ActivityPanel.js +2 -2
- package/lib/client/locales.js +0 -2
- package/lib/client.js +30 -32
- package/lib/client.js.map +1 -1
- package/lib/harness-compat.js +159 -0
- package/lib/index.js +8 -40
- package/lib/members.js +177 -64
- package/lib/quality-gates.js +45 -0
- package/lib/scheduler.js +37 -12
- package/lib/state.js +10 -5
- package/lib/tools.js +66 -62
- package/lib/types/client/locales.d.ts +0 -2
- package/lib/types/harness-compat.d.ts +22 -0
- package/lib/types/members.d.ts +19 -5
- package/lib/types/quality-gates.d.ts +8 -0
- package/lib/types/scheduler.d.ts +7 -1
- package/lib/types/state.d.ts +1 -1
- package/lib/types/tools.d.ts +1 -1
- package/lib/types/web-routes.d.ts +6 -0
- package/lib/web-routes.js +60 -0
- package/package.json +68 -58
- package/release-notes/v0.1.11.md +1 -1
- package/release-notes/v0.1.16-rc.1.md +33 -0
- package/scripts/compatibility.mjs +90 -0
- package/scripts/doctor.mjs +126 -0
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { SubagentError } from '@deepseek-ai/dsh-subagent';
|
|
2
|
+
/**
|
|
3
|
+
* Exact protocol exported by dsh-subagent/internal in Alpha.5 and rc.1.
|
|
4
|
+
* That subpath does not exist in Alpha.2, so importing it statically prevents
|
|
5
|
+
* the plugin from loading there. This small adapter uses the same process-
|
|
6
|
+
* stable symbol and call signature as upstream queueHostSubagentPrompt.
|
|
7
|
+
* Source: packages/subagent/subagent/src/internal.ts at dsh-v0.1.2-rc.1.
|
|
8
|
+
*/
|
|
9
|
+
const hostPromptQueue = Symbol.for('dsh.subagent.queuePrompt');
|
|
10
|
+
function boundary(runtime) {
|
|
11
|
+
return runtime;
|
|
12
|
+
}
|
|
13
|
+
function unsupported(detail) {
|
|
14
|
+
throw new Error(`agent-teams: unsupported Harness subagent contract (${detail}); use an explicitly tested Harness version and a coherent dependency installation`);
|
|
15
|
+
}
|
|
16
|
+
/** Read child-owned history, excluding any descriptor inherited from a parent. */
|
|
17
|
+
export function sessionOwnEvents(session) {
|
|
18
|
+
const current = session;
|
|
19
|
+
if (typeof current.ownEvents === 'function')
|
|
20
|
+
return current.ownEvents.call(session);
|
|
21
|
+
const legacy = session;
|
|
22
|
+
if (!Array.isArray(legacy.events))
|
|
23
|
+
return unsupported('missing ownEvents/legacy session log');
|
|
24
|
+
return legacy.events.slice(legacy.header.seedLength ?? 0);
|
|
25
|
+
}
|
|
26
|
+
/** Install before the first request, including cold resume, with HMR cleanup. */
|
|
27
|
+
export function installContinuableMemberSetup(ctx, setup) {
|
|
28
|
+
const runtime = boundary(ctx.subagents);
|
|
29
|
+
if (typeof runtime.registerContinuableSetup === 'function') {
|
|
30
|
+
// Upstream owns this registration with this.ctx.effect. Cordis resolves
|
|
31
|
+
// that ctx to the accessing plugin, so its disposal revokes installations
|
|
32
|
+
// even while the subagents service and child Agents remain live.
|
|
33
|
+
runtime.registerContinuableSetup.call(ctx.subagents, setup);
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
if (typeof runtime[hostPromptQueue] !== 'function' || typeof runtime.sendMessage !== 'function') {
|
|
37
|
+
return unsupported('missing continuable setup and modern host queue');
|
|
38
|
+
}
|
|
39
|
+
const installed = new WeakSet();
|
|
40
|
+
const active = new Set();
|
|
41
|
+
ctx.effect(() => {
|
|
42
|
+
const stop = ctx.on('agent/session-start', ({ agent }) => {
|
|
43
|
+
if (installed.has(agent))
|
|
44
|
+
return;
|
|
45
|
+
// Deliberately synchronous: awaiting here loses the first-request race.
|
|
46
|
+
let teardown;
|
|
47
|
+
try {
|
|
48
|
+
teardown = setup(agent.ctx);
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
// session-start is a notification: Harness logs a thrown listener and
|
|
52
|
+
// still admits the first prompt. Reject request assembly explicitly so
|
|
53
|
+
// a malformed saved route cannot silently execute on a default model.
|
|
54
|
+
const failure = new Error(`agent-teams: member initialization failed: ${String(error)}`, { cause: error });
|
|
55
|
+
ctx.logger.warn(failure.message);
|
|
56
|
+
teardown = agent.ctx.on('agent/request', () => { throw failure; });
|
|
57
|
+
}
|
|
58
|
+
installed.add(agent);
|
|
59
|
+
let disposed = false;
|
|
60
|
+
const dispose = () => {
|
|
61
|
+
if (disposed)
|
|
62
|
+
return;
|
|
63
|
+
disposed = true;
|
|
64
|
+
active.delete(dispose);
|
|
65
|
+
installed.delete(agent);
|
|
66
|
+
teardown();
|
|
67
|
+
};
|
|
68
|
+
active.add(dispose);
|
|
69
|
+
// Listeners contributed to agent.ctx already follow its lifetime. Also
|
|
70
|
+
// release our bookkeeping and remove them if this plugin is reloaded.
|
|
71
|
+
try {
|
|
72
|
+
agent.ctx.effect(() => dispose, 'agent-teams: child compatibility setup');
|
|
73
|
+
}
|
|
74
|
+
catch (error) {
|
|
75
|
+
dispose();
|
|
76
|
+
throw error;
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
return () => {
|
|
80
|
+
stop();
|
|
81
|
+
for (const dispose of [...active])
|
|
82
|
+
dispose();
|
|
83
|
+
};
|
|
84
|
+
}, 'agent-teams: member lifecycle compatibility');
|
|
85
|
+
}
|
|
86
|
+
/** Queue a distinct host-authored turn; never substitute model-message steer. */
|
|
87
|
+
export async function queueMemberPrompt(runtime, parent, childId, content, signal) {
|
|
88
|
+
const host = boundary(runtime);
|
|
89
|
+
const source = { kind: 'plugin', plugin: 'dsh-agent-teams' };
|
|
90
|
+
if (typeof host.followup === 'function') {
|
|
91
|
+
return host.followup.call(runtime, parent, childId, content, { source, signal });
|
|
92
|
+
}
|
|
93
|
+
const queue = host[hostPromptQueue];
|
|
94
|
+
if (typeof queue !== 'function')
|
|
95
|
+
return unsupported('missing host FIFO delivery');
|
|
96
|
+
return queue.call(runtime, parent, childId, content, source, signal);
|
|
97
|
+
}
|
|
98
|
+
/** Guard all resumable delivery paths, preserving the native service receiver. */
|
|
99
|
+
export function guardSubagentDelivery(ctx, isRetired) {
|
|
100
|
+
const runtime = ctx.subagents;
|
|
101
|
+
const host = boundary(runtime);
|
|
102
|
+
const legacy = host.followup;
|
|
103
|
+
const queue = host[hostPromptQueue];
|
|
104
|
+
const send = host.sendMessage;
|
|
105
|
+
if (typeof legacy !== 'function' && (typeof queue !== 'function' || typeof send !== 'function')) {
|
|
106
|
+
return unsupported('cannot install complete retired-member guard');
|
|
107
|
+
}
|
|
108
|
+
ctx.effect(() => {
|
|
109
|
+
const descriptors = new Map([
|
|
110
|
+
['followup', Object.getOwnPropertyDescriptor(host, 'followup')],
|
|
111
|
+
[hostPromptQueue, Object.getOwnPropertyDescriptor(host, hostPromptQueue)],
|
|
112
|
+
['sendMessage', Object.getOwnPropertyDescriptor(host, 'sendMessage')],
|
|
113
|
+
]);
|
|
114
|
+
let active = true;
|
|
115
|
+
const check = async (sender, targetId) => {
|
|
116
|
+
if (active && await isRetired(sender, targetId)) {
|
|
117
|
+
throw new SubagentError(`AgentTeams member "${targetId}" was retired and cannot be resumed`, 'NOT_RESUMABLE');
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
const guardedLegacy = async (parent, childId, content, options) => {
|
|
121
|
+
await check(parent, childId);
|
|
122
|
+
return legacy.call(runtime, parent, childId, content, options);
|
|
123
|
+
};
|
|
124
|
+
const guardedQueue = async (parent, childId, content, source, signal) => {
|
|
125
|
+
await check(parent, childId);
|
|
126
|
+
return queue.call(runtime, parent, childId, content, source, signal);
|
|
127
|
+
};
|
|
128
|
+
const guardedSend = async (sender, targetId, content, options) => {
|
|
129
|
+
await check(sender, targetId);
|
|
130
|
+
return send.call(runtime, sender, targetId, content, options);
|
|
131
|
+
};
|
|
132
|
+
if (typeof legacy === 'function')
|
|
133
|
+
host.followup = guardedLegacy;
|
|
134
|
+
if (typeof queue === 'function')
|
|
135
|
+
host[hostPromptQueue] = guardedQueue;
|
|
136
|
+
if (typeof send === 'function')
|
|
137
|
+
host.sendMessage = guardedSend;
|
|
138
|
+
// Cordis wraps method reads in fresh Proxies. Compare the actual own
|
|
139
|
+
// descriptor to restore only our contribution, including prototype methods.
|
|
140
|
+
const restore = (key, installed) => {
|
|
141
|
+
if (Object.getOwnPropertyDescriptor(host, key)?.value !== installed)
|
|
142
|
+
return;
|
|
143
|
+
const original = descriptors.get(key);
|
|
144
|
+
if (original === undefined)
|
|
145
|
+
Reflect.deleteProperty(host, key);
|
|
146
|
+
else
|
|
147
|
+
Object.defineProperty(host, key, original);
|
|
148
|
+
};
|
|
149
|
+
return () => {
|
|
150
|
+
active = false;
|
|
151
|
+
if (typeof legacy === 'function')
|
|
152
|
+
restore('followup', guardedLegacy);
|
|
153
|
+
if (typeof queue === 'function')
|
|
154
|
+
restore(hostPromptQueue, guardedQueue);
|
|
155
|
+
if (typeof send === 'function')
|
|
156
|
+
restore('sendMessage', guardedSend);
|
|
157
|
+
};
|
|
158
|
+
}, 'agent-teams: retired member guard');
|
|
159
|
+
}
|
package/lib/index.js
CHANGED
|
@@ -26,7 +26,7 @@ import { collectArchivedTeamsActivity, collectTeamsActivity } from "./snapshot.j
|
|
|
26
26
|
import { findTeamByCaptain } from "./state.js";
|
|
27
27
|
import { formatProfilesForPrompt } from "./profiles.js";
|
|
28
28
|
import { qualityPlanningPrompt } from "./quality-gates.js";
|
|
29
|
-
import { authenticatedWebRoutes } from "./web-routes.js";
|
|
29
|
+
import { authenticatedWebRoutes, readJsonRequest, RequestBodyError } from "./web-routes.js";
|
|
30
30
|
/** Web-server service key candidates, newest first. */
|
|
31
31
|
const WEB_SERVER_KEYS = ['webServer', 'httpServer'];
|
|
32
32
|
/** Workspace registry service key candidates, newest first. */
|
|
@@ -89,7 +89,7 @@ export function usageSectionText(toolNames, profilesText = '') {
|
|
|
89
89
|
3. Analyze the goal and create the smallest useful task DAG while staged. Every agent_teams_create_task call must include a non-empty subject, including verification and review tasks. Independent work should be parallel; dependencies are only genuine prerequisites. Finish the complete roster and DAG, tell the user the Web plan is ready, then end this turn. Never call agent_teams_approve during the planning turn. The user may click Approve & Run, explicitly approve in a later user turn, return to chat to request changes, or discard the plan. The review UI injects an authoritative control message for return/discard actions: follow it exactly and never infer that a missing or paused team should be recreated. When the user returns to chat, first ask one concise clarification question without editing or recreating; after their answer, call agent_teams_edit_plan once with an ordered atomic batch, update downstream dependencies/assignees before removals, summarize the revision, and wait for review again. Never inspect or edit .agent-teams state files or plugin source code to revise a plan. Only explicit approval may call agent_teams_approve.
|
|
90
90
|
4. After approval, the final member configuration is spawned atomically and the scheduler starts ready work. Lead by delegation: monitor with agent_teams_status, send guidance with agent_teams_send_message, and let idle teammates execute ready work. Do not duplicate a teammate's work merely because its turn is slow. If the user requires every member to contribute or report, create one task per required contribution (or message each member directly); never wait for an unassigned member to produce work it was never given.
|
|
91
91
|
5. If the user explicitly asks to pause a running member, its open attempt remains parked after interruption; after answering the user, send that same member guidance with agent_teams_send_message so it continues the same attempt. Do not interrupt members for an ordinary user question that did not request a pause. If work must change owner, restart from scratch, or be taken over, call agent_teams_reassign_task first. Prefer another idle member or a retry with the same member. Use assignee=captain only for one ready task that you will personally drive to a terminal status in this same turn; never start a second captain takeover while one is unfinished, and never end your turn with captain-owned work open. Reassignment revokes the old attempt and waits for that member to quiesce, preventing late results from overwriting the new attempt.
|
|
92
|
-
6. Tasks carry attempt_id capabilities. Members must use the current attempt_id for updates; stale-attempt errors mean ownership changed.
|
|
92
|
+
6. Tasks carry attempt_id capabilities. Members must use the current attempt_id for updates; stale-attempt errors mean ownership changed. After dispatch, end your turn while members work: mailbox progress deliveries will wake you. Read status after a delivery or an explicit user request until every required task is terminal and every member is idle/ready; do not busy-poll or require reports from members with no assigned work.
|
|
93
93
|
7. If the user names a configured profile / template / fixed roster, pass that name as profile= to agent_teams_create. After a successful profile create, do not recreate the same members. Seed profiles provide their template tasks; captain-planning profiles provide only the roster and guardrails, so you must design their DAG while staged. Add repair or retry tasks when review/test fails, but never make a new task depend on a failed task. Do not send_message to start the next stage; the scheduler assigns ready work after approval. Watch every required task until it is terminal before deleting the team. Never perform a real deployment without explicit user confirmation.
|
|
94
94
|
8. Quality kinds (requirements, implementation, verification, review, repair, integration) need a contract: non-empty objective and acceptance; implementation/repair also need inScope and verify. Review/requirements can complete only with verdict=pass; needs_revision/reject must fail with findings. The system then opens repair + next review that depend on the successful source, never the failed review. Do not approve your own implementation. create_task no longer silently resumes a halted team — call agent_teams_resume with a reason, or create_task({resume:true, resumeReason}).
|
|
95
95
|
9. ${qualityPlanningPrompt()}
|
|
@@ -199,27 +199,13 @@ export function apply(ctx, config) {
|
|
|
199
199
|
res.end();
|
|
200
200
|
return;
|
|
201
201
|
}
|
|
202
|
-
let raw = '';
|
|
203
|
-
try {
|
|
204
|
-
raw = await new Promise((resolve, reject) => {
|
|
205
|
-
const chunks = [];
|
|
206
|
-
req.on('data', (chunk) => { chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); });
|
|
207
|
-
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
|
208
|
-
req.on('error', reject);
|
|
209
|
-
});
|
|
210
|
-
}
|
|
211
|
-
catch {
|
|
212
|
-
res.writeHead(400, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
|
|
213
|
-
res.end(JSON.stringify({ error: 'invalid request body' }));
|
|
214
|
-
return;
|
|
215
|
-
}
|
|
216
202
|
let payload;
|
|
217
203
|
try {
|
|
218
|
-
payload =
|
|
204
|
+
payload = await readJsonRequest(req);
|
|
219
205
|
}
|
|
220
|
-
catch {
|
|
221
|
-
res.writeHead(400, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
|
|
222
|
-
res.end(JSON.stringify({ error: 'invalid
|
|
206
|
+
catch (error) {
|
|
207
|
+
res.writeHead(error instanceof RequestBodyError ? error.status : 400, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
|
|
208
|
+
res.end(JSON.stringify({ error: error instanceof Error ? error.message : 'invalid request body' }));
|
|
223
209
|
return;
|
|
224
210
|
}
|
|
225
211
|
const sessionId = typeof payload.sessionId === 'string' ? payload.sessionId.trim() : '';
|
|
@@ -271,28 +257,10 @@ export function apply(ctx, config) {
|
|
|
271
257
|
}
|
|
272
258
|
let payload;
|
|
273
259
|
try {
|
|
274
|
-
|
|
275
|
-
const raw = await new Promise((resolve, reject) => {
|
|
276
|
-
let size = 0;
|
|
277
|
-
req.on('data', (chunk) => {
|
|
278
|
-
const part = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
279
|
-
size += part.length;
|
|
280
|
-
if (size > 1_000_000) {
|
|
281
|
-
reject(new Error('request body is too large'));
|
|
282
|
-
return;
|
|
283
|
-
}
|
|
284
|
-
chunks.push(part);
|
|
285
|
-
});
|
|
286
|
-
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
|
287
|
-
req.on('error', reject);
|
|
288
|
-
});
|
|
289
|
-
const parsed = raw.trim() === '' ? {} : JSON.parse(raw);
|
|
290
|
-
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
|
|
291
|
-
throw new Error('body must be an object');
|
|
292
|
-
payload = parsed;
|
|
260
|
+
payload = await readJsonRequest(req);
|
|
293
261
|
}
|
|
294
262
|
catch (error) {
|
|
295
|
-
res.writeHead(400, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
|
|
263
|
+
res.writeHead(error instanceof RequestBodyError ? error.status : 400, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
|
|
296
264
|
res.end(JSON.stringify({ error: error instanceof Error ? error.message : 'invalid request body' }));
|
|
297
265
|
return;
|
|
298
266
|
}
|
package/lib/members.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
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
|
|
7
|
-
*
|
|
7
|
+
* queues its next turn through {@link deliverToMember}, it works through its turn
|
|
8
8
|
* (updating team state through the `agent_teams_*` tools), and becomes idle
|
|
9
9
|
* again. Its final assistant message is not readable programmatically, so the
|
|
10
10
|
* member persists its report into the captain's mailbox and the task records,
|
|
@@ -13,10 +13,12 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import { installModelSelection } from '@deepseek-ai/dsh-agent';
|
|
15
15
|
// Declaration merge only: makes ctx.subagents visible.
|
|
16
|
-
import { foldSubagentDescriptor
|
|
17
|
-
import { ReasoningEffortId } from '@deepseek-ai/dsh-llm';
|
|
16
|
+
import { foldSubagentDescriptor } from '@deepseek-ai/dsh-subagent';
|
|
17
|
+
import { createUserMessage, LlmError, ReasoningEffortId } from '@deepseek-ai/dsh-llm';
|
|
18
18
|
import { join } from 'node:path';
|
|
19
|
-
import {
|
|
19
|
+
import { guardSubagentDelivery, installContinuableMemberSetup, queueMemberPrompt, sessionOwnEvents } from "./harness-compat.js";
|
|
20
|
+
import { acknowledgeMailbox, appendMailbox, CAPTAIN_KEY, createMessage, readRetiredMemberIds, readTeamSync, readTeam, releaseMailboxDelivery, withTeamLock, writeTeam } from "./state.js";
|
|
21
|
+
import { appendTeamEvent, captainSessionOf } from "./events.js";
|
|
20
22
|
import { TERMINAL_TASK_STATUSES } from "./types.js";
|
|
21
23
|
/** Persona snapshot of a profile protocol; the full text lives on team.json. */
|
|
22
24
|
export const PERSONA_PROTOCOL_MAX_CHARS = 400;
|
|
@@ -74,6 +76,81 @@ export function selectFallbackRoute(current, fallback, failureCode, alreadySwitc
|
|
|
74
76
|
}
|
|
75
77
|
return { retry: true, switched: true, selection: fallback };
|
|
76
78
|
}
|
|
79
|
+
/** Deliver a durable member report to the live captain at its next model step. */
|
|
80
|
+
export function steerCaptainReport(captain, from, content) {
|
|
81
|
+
try {
|
|
82
|
+
captain.steer(createUserMessage({
|
|
83
|
+
content: [{ type: 'text', text: `AgentTeams message from member ${from}:\n\n${content}` }],
|
|
84
|
+
source: { kind: 'plugin', plugin: 'dsh-agent-teams' },
|
|
85
|
+
}));
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
/** Record a final turn failure, never an intermediate request retry. */
|
|
93
|
+
export async function failMemberOpenAttempt(ctx, stateRoot, teamId, memberName, failure, fallbackSession, observed) {
|
|
94
|
+
const summary = `${failure.message} (code ${failure.code})`;
|
|
95
|
+
const lockKey = `team:${stateRoot}:${teamId}`;
|
|
96
|
+
const prepared = await withTeamLock(lockKey, async () => {
|
|
97
|
+
const team = await readTeam(stateRoot, teamId);
|
|
98
|
+
if (team === undefined || team.halted === true || team.captainSessionId !== observed.captainSessionId)
|
|
99
|
+
return;
|
|
100
|
+
const member = team.members.find(candidate => candidate.name === memberName
|
|
101
|
+
&& candidate.id === observed.memberId && candidate.status !== 'removed');
|
|
102
|
+
if (member === undefined)
|
|
103
|
+
return;
|
|
104
|
+
const task = team.tasks.find(candidate => candidate.assignee === memberName
|
|
105
|
+
&& (candidate.status === 'claimed' || candidate.status === 'in_progress'));
|
|
106
|
+
// A reassign, completion, or recreated member may win the lock while the
|
|
107
|
+
// final error is queued. Only the capability observed at that event may fail.
|
|
108
|
+
if (task?.id !== observed.task?.id || task?.attemptId !== observed.task?.attemptId
|
|
109
|
+
|| task?.attempt !== observed.task?.attempt)
|
|
110
|
+
return;
|
|
111
|
+
if (task === undefined && member.status !== 'working')
|
|
112
|
+
return;
|
|
113
|
+
if (task !== undefined) {
|
|
114
|
+
task.status = 'failed';
|
|
115
|
+
task.output = summary;
|
|
116
|
+
task.updatedAt = Date.now();
|
|
117
|
+
}
|
|
118
|
+
if (ctx.agents.get(brandedSessionId(member.id))?.status !== 'running')
|
|
119
|
+
member.status = 'idle';
|
|
120
|
+
const message = {
|
|
121
|
+
...createMessage(memberName, CAPTAIN_KEY, task === undefined
|
|
122
|
+
? `Member "${memberName}" hit an unrecoverable turn failure: ${summary}. No open attempt was owned.`
|
|
123
|
+
: `Member "${memberName}" hit an unrecoverable turn failure: ${summary}. Task ${task.id} ("${task.subject}") was marked failed; reassign it or retry when ready.`),
|
|
124
|
+
deliveryClaimedAt: Date.now(),
|
|
125
|
+
};
|
|
126
|
+
await writeTeam(stateRoot, team);
|
|
127
|
+
await appendMailbox(stateRoot, team.id, CAPTAIN_KEY, message);
|
|
128
|
+
if (task !== undefined) {
|
|
129
|
+
appendTeamEvent(ctx, captainSessionOf(ctx, team.captainSessionId, fallbackSession), 'agent-teams/task-updated', {
|
|
130
|
+
teamId, taskId: task.id, status: task.status, assignee: memberName, output: task.output,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
appendTeamEvent(ctx, captainSessionOf(ctx, team.captainSessionId, fallbackSession), 'agent-teams/message-sent', {
|
|
134
|
+
teamId: team.id,
|
|
135
|
+
messageId: message.id,
|
|
136
|
+
from: memberName,
|
|
137
|
+
to: CAPTAIN_KEY,
|
|
138
|
+
content: message.content,
|
|
139
|
+
ts: message.ts,
|
|
140
|
+
});
|
|
141
|
+
return { captainSessionId: team.captainSessionId, message };
|
|
142
|
+
});
|
|
143
|
+
if (prepared === undefined)
|
|
144
|
+
return false;
|
|
145
|
+
// Use the same lease/acknowledgment contract as send_message, outside the
|
|
146
|
+
// team lock: steering can synchronously start another agent turn.
|
|
147
|
+
const captain = ctx.agents.get(brandedSessionId(prepared.captainSessionId));
|
|
148
|
+
const delivered = captain !== undefined && steerCaptainReport(captain, memberName, prepared.message.content);
|
|
149
|
+
await withTeamLock(lockKey, () => delivered
|
|
150
|
+
? acknowledgeMailbox(stateRoot, teamId, CAPTAIN_KEY, [prepared.message.id])
|
|
151
|
+
: releaseMailboxDelivery(stateRoot, teamId, CAPTAIN_KEY, [prepared.message.id]));
|
|
152
|
+
return true;
|
|
153
|
+
}
|
|
77
154
|
async function updateFallbackState(stateRoot, teamId, memberName, fallback, ctx) {
|
|
78
155
|
await withTeamLock(`team:${stateRoot}:${teamId}`, async () => {
|
|
79
156
|
const team = await readTeam(stateRoot, teamId);
|
|
@@ -99,12 +176,14 @@ function selectionFromMember(member) {
|
|
|
99
176
|
const model = (member.activeModel ?? member.model).trim();
|
|
100
177
|
if (provider === '' || model === '')
|
|
101
178
|
return undefined;
|
|
102
|
-
|
|
179
|
+
// Effort ids belong to the original model. After a fallback, its own
|
|
180
|
+
// provider default remains authoritative, including after cold recovery.
|
|
181
|
+
const reasoningEffort = member.fallbackActive === true ? undefined : member.reasoningEffort?.trim();
|
|
103
182
|
return {
|
|
104
183
|
provider,
|
|
105
184
|
model,
|
|
106
185
|
...reasoningEffort === undefined || reasoningEffort === '' ? {} : { reasoningEffort },
|
|
107
|
-
...member.fallback === undefined ? {} : { fallback: member.fallback },
|
|
186
|
+
...member.fallback === undefined || member.fallbackActive === true ? {} : { fallback: member.fallback },
|
|
108
187
|
};
|
|
109
188
|
}
|
|
110
189
|
function modelSelection(selection) {
|
|
@@ -189,73 +268,119 @@ export async function resolveMemberLlmSelection(ctx, captain, request, signal) {
|
|
|
189
268
|
* record. Legacy members without a complete saved route retain Harness's
|
|
190
269
|
* descriptor provider/model behavior.
|
|
191
270
|
*/
|
|
192
|
-
export function installMemberSelectionRuntime(ctx, stateDir) {
|
|
271
|
+
export function installMemberSelectionRuntime(ctx, stateDir, onFailureSettled) {
|
|
193
272
|
const pending = new Map();
|
|
194
|
-
ctx
|
|
273
|
+
installContinuableMemberSetup(ctx, (childCtx) => {
|
|
195
274
|
const child = childCtx.agent;
|
|
196
275
|
if (child === undefined)
|
|
197
276
|
return () => undefined;
|
|
198
|
-
const
|
|
199
|
-
const descriptor = foldSubagentDescriptor(suffix);
|
|
277
|
+
const descriptor = foldSubagentDescriptor(sessionOwnEvents(child.session));
|
|
200
278
|
if (descriptor?.mode !== 'continuable' || !descriptor.label.startsWith(MEMBER_LABEL_PREFIX)) {
|
|
201
279
|
return () => undefined;
|
|
202
280
|
}
|
|
203
281
|
const parentSessionId = child.session.header.parentSession;
|
|
204
282
|
if (parentSessionId === undefined)
|
|
205
283
|
return () => undefined;
|
|
284
|
+
const identity = descriptor.label.slice(MEMBER_LABEL_PREFIX.length);
|
|
285
|
+
const separator = identity.indexOf(':');
|
|
286
|
+
if (separator < 1 || separator === identity.length - 1)
|
|
287
|
+
return () => undefined;
|
|
288
|
+
const teamId = identity.slice(0, separator);
|
|
289
|
+
const memberName = identity.slice(separator + 1);
|
|
290
|
+
const workspace = child.session.header.cwd ?? process.cwd();
|
|
291
|
+
const stateRoot = join(workspace, stateDir);
|
|
206
292
|
const key = pendingSelectionKey(parentSessionId, descriptor.label);
|
|
207
293
|
let selection = pending.get(key);
|
|
208
294
|
if (selection === undefined) {
|
|
209
|
-
const
|
|
210
|
-
const separator = identity.indexOf(':');
|
|
211
|
-
if (separator < 1 || separator === identity.length - 1)
|
|
212
|
-
return () => undefined;
|
|
213
|
-
const teamId = identity.slice(0, separator);
|
|
214
|
-
const memberName = identity.slice(separator + 1);
|
|
215
|
-
const workspace = child.session.header.cwd ?? process.cwd();
|
|
216
|
-
const team = readTeamSync(join(workspace, stateDir), teamId);
|
|
295
|
+
const team = readTeamSync(stateRoot, teamId);
|
|
217
296
|
if (team?.captainSessionId !== parentSessionId)
|
|
218
297
|
return () => undefined;
|
|
219
298
|
const durableMember = team.members.find(member => member.name === memberName);
|
|
220
299
|
selection = selectionFromMember(durableMember);
|
|
221
|
-
|
|
222
|
-
// Harness descriptor still restores provider/model, so leave it alone.
|
|
223
|
-
if (selection === undefined)
|
|
224
|
-
return () => undefined;
|
|
225
|
-
if (descriptor.agentProvider !== durableMember?.provider || descriptor.agentModel !== durableMember?.model) {
|
|
300
|
+
if (selection !== undefined && (descriptor.agentProvider !== durableMember?.provider || descriptor.agentModel !== durableMember?.model)) {
|
|
226
301
|
throw new Error(`agent-teams: saved model route for member "${memberName}" does not match its subagent descriptor`);
|
|
227
302
|
}
|
|
228
303
|
}
|
|
304
|
+
let lastFailedTurn;
|
|
305
|
+
// Harness emits agent/error only after request recovery is exhausted. In
|
|
306
|
+
// particular, llm-retry's "always" policy calls downstream request-error
|
|
307
|
+
// listeners before it retries; that waterfall cannot declare a turn dead.
|
|
308
|
+
const disposeFailure = childCtx.on('agent/error', async (payload) => {
|
|
309
|
+
if (payload.agent.id !== child.id || payload.turn === lastFailedTurn)
|
|
310
|
+
return;
|
|
311
|
+
lastFailedTurn = payload.turn;
|
|
312
|
+
try {
|
|
313
|
+
// Capture the attempt synchronously at the event, before any lock wait
|
|
314
|
+
// can let a captain reassign it or replace the member/team generation.
|
|
315
|
+
const snapshot = readTeamSync(stateRoot, teamId);
|
|
316
|
+
if (snapshot?.captainSessionId !== parentSessionId)
|
|
317
|
+
return;
|
|
318
|
+
const member = snapshot.members.find(item => item.id === child.id && item.name === memberName && item.status !== 'removed');
|
|
319
|
+
if (member === undefined)
|
|
320
|
+
return;
|
|
321
|
+
const task = snapshot.tasks.find(item => item.assignee === memberName
|
|
322
|
+
&& (item.status === 'claimed' || item.status === 'in_progress'));
|
|
323
|
+
const failure = payload.error instanceof LlmError ? payload.error.failure : {
|
|
324
|
+
code: 'UNKNOWN',
|
|
325
|
+
message: payload.error instanceof Error ? payload.error.message : String(payload.error),
|
|
326
|
+
};
|
|
327
|
+
const recorded = await failMemberOpenAttempt(ctx, stateRoot, teamId, memberName, failure, child.session, {
|
|
328
|
+
captainSessionId: parentSessionId, memberId: child.id, task,
|
|
329
|
+
});
|
|
330
|
+
if (!recorded)
|
|
331
|
+
return;
|
|
332
|
+
// The final-error event precedes driver quiescence. Observe the real
|
|
333
|
+
// lifecycle and kick explicitly even if its idle event was missed.
|
|
334
|
+
// Never force an active Agent's status to idle or retry the failed task.
|
|
335
|
+
await child.whenIdle();
|
|
336
|
+
await withTeamLock(`team:${stateRoot}:${teamId}`, async () => {
|
|
337
|
+
const team = await readTeam(stateRoot, teamId);
|
|
338
|
+
const current = team?.members.find(item => item.id === child.id && item.name === memberName && item.status !== 'removed');
|
|
339
|
+
if (team?.captainSessionId !== parentSessionId || current === undefined || child.status !== 'idle')
|
|
340
|
+
return;
|
|
341
|
+
if (current.status !== 'idle') {
|
|
342
|
+
current.status = 'idle';
|
|
343
|
+
await writeTeam(stateRoot, team);
|
|
344
|
+
}
|
|
345
|
+
});
|
|
346
|
+
await onFailureSettled?.(workspace, teamId, memberName);
|
|
347
|
+
}
|
|
348
|
+
catch (error) {
|
|
349
|
+
ctx.logger.warn(`agent-teams: failed to record member turn failure: ${String(error)}`);
|
|
350
|
+
}
|
|
351
|
+
});
|
|
352
|
+
// Legacy teams still need failure reporting even without a saved route.
|
|
353
|
+
if (selection === undefined)
|
|
354
|
+
return disposeFailure;
|
|
229
355
|
const selectionRef = { current: modelSelection(selection), assembled: undefined };
|
|
230
356
|
const disposeSelection = installModelSelection(childCtx, selectionRef);
|
|
231
357
|
const fallback = selection.fallback;
|
|
232
|
-
if (fallback === undefined)
|
|
233
|
-
return disposeSelection;
|
|
234
358
|
let switched = false;
|
|
235
|
-
const disposeFallback = childCtx.on('agent/request-error', async (payload) => {
|
|
236
|
-
if (payload.agent.id !== child.id)
|
|
237
|
-
return
|
|
359
|
+
const disposeFallback = childCtx.on('agent/request-error', async (payload, next) => {
|
|
360
|
+
if (payload.agent.id !== child.id || payload.signal.aborted)
|
|
361
|
+
return next();
|
|
238
362
|
const transition = selectFallbackRoute(selectionRef.current ?? { provider: selection.provider, model: selection.model }, fallback, payload.failure.code, switched);
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
void updateFallbackState(join(workspace, stateDir), teamId, memberName, fallback, ctx).catch((error) => {
|
|
363
|
+
// selectFallbackRoute only retries when a fallback route exists; the
|
|
364
|
+
// explicit guard narrows that fact for the compiler.
|
|
365
|
+
if (fallback !== undefined && transition.retry) {
|
|
366
|
+
switched = transition.switched;
|
|
367
|
+
selectionRef.current = transition.selection;
|
|
368
|
+
// Request recovery repeats buildRequest inside the current step; it
|
|
369
|
+
// does not re-run prompt assembly. Override that captured route too,
|
|
370
|
+
// otherwise the authorized retry would hit the failed primary again.
|
|
371
|
+
selectionRef.assembled = transition.selection;
|
|
372
|
+
await updateFallbackState(stateRoot, teamId, memberName, fallback, ctx).catch((error) => {
|
|
250
373
|
ctx.logger.warn(`agent-teams: failed to persist fallback route: ${String(error)}`);
|
|
251
374
|
});
|
|
375
|
+
ctx.logger.warn(`agent-teams: member ${child.id} switching to fallback ${fallback.provider}/${fallback.model} after ${payload.failure.code}`);
|
|
376
|
+
return { kind: 'retry' };
|
|
252
377
|
}
|
|
253
|
-
|
|
254
|
-
return { kind: 'retry' };
|
|
378
|
+
return next();
|
|
255
379
|
});
|
|
256
380
|
return () => {
|
|
257
381
|
disposeFallback();
|
|
258
382
|
disposeSelection();
|
|
383
|
+
disposeFailure();
|
|
259
384
|
};
|
|
260
385
|
});
|
|
261
386
|
return {
|
|
@@ -387,6 +512,9 @@ export async function spawnMember(ctx, config, selections, llmSelection, captain
|
|
|
387
512
|
agentOptions: {
|
|
388
513
|
provider: llmSelection.provider,
|
|
389
514
|
model: llmSelection.model,
|
|
515
|
+
...llmSelection.reasoningEffort === undefined
|
|
516
|
+
? {}
|
|
517
|
+
: { reasoningEffort: ReasoningEffortId(llmSelection.reasoningEffort) },
|
|
390
518
|
},
|
|
391
519
|
...config.maxDepth !== undefined ? { maxDepth: config.maxDepth } : {},
|
|
392
520
|
},
|
|
@@ -413,14 +541,11 @@ export async function spawnMember(ctx, config, selections, llmSelection, captain
|
|
|
413
541
|
*/
|
|
414
542
|
export async function deliverToMember(ctx, captain, childId, text, signal) {
|
|
415
543
|
try {
|
|
416
|
-
await ctx.subagents
|
|
417
|
-
source: { kind: 'plugin', plugin: 'dsh-agent-teams' },
|
|
418
|
-
signal,
|
|
419
|
-
});
|
|
544
|
+
await queueMemberPrompt(ctx.subagents, captain, brandedSessionId(childId), [{ type: 'text', text }], signal);
|
|
420
545
|
return true;
|
|
421
546
|
}
|
|
422
547
|
catch (error) {
|
|
423
|
-
ctx.logger.warn(`agent-teams:
|
|
548
|
+
ctx.logger.warn(`agent-teams: prompt delivery to member ${childId} failed: ${String(error)}`);
|
|
424
549
|
return false;
|
|
425
550
|
}
|
|
426
551
|
}
|
|
@@ -444,30 +569,18 @@ export function interruptMember(ctx, captain, childId) {
|
|
|
444
569
|
*
|
|
445
570
|
* Upstream `interrupt()` deliberately preserves continuable sessions and the
|
|
446
571
|
* upstream seam exposes no targeted forget/retire method. The durable
|
|
447
|
-
* AgentTeams index therefore rejects
|
|
572
|
+
* AgentTeams index therefore rejects every inbox delivery before it can cold-resume a
|
|
448
573
|
* retired member. Catalog rows deliberately remain discoverable: Harness rc.8
|
|
449
574
|
* uses the direct-child catalog to authorize historical transcript reads and
|
|
450
575
|
* `openSubagent()`, so filtering those rows would make an archived member's
|
|
451
576
|
* persisted conversation inaccessible. Exact ids keep unrelated subagents
|
|
452
|
-
* untouched while the
|
|
577
|
+
* untouched while the delivery boundary still prevents further model turns.
|
|
453
578
|
*/
|
|
454
579
|
export function installRetiredMemberGuard(ctx, stateDir) {
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
const retired = await readRetiredMemberIds(join(parent.session.header.cwd ?? process.cwd(), stateDir));
|
|
460
|
-
if (retired.has(childId)) {
|
|
461
|
-
throw new SubagentError(`AgentTeams member "${childId}" was retired and cannot be resumed`, 'NOT_RESUMABLE');
|
|
462
|
-
}
|
|
463
|
-
return followup.call(runtime, parent, childId, content, options);
|
|
464
|
-
};
|
|
465
|
-
runtime.followup = guardedFollowup;
|
|
466
|
-
return () => {
|
|
467
|
-
if (runtime.followup === guardedFollowup)
|
|
468
|
-
runtime.followup = followup;
|
|
469
|
-
};
|
|
470
|
-
}, 'agent-teams: retired member guard');
|
|
580
|
+
guardSubagentDelivery(ctx, async (parent, childId) => {
|
|
581
|
+
const retired = await readRetiredMemberIds(join(parent.session.header.cwd ?? process.cwd(), stateDir));
|
|
582
|
+
return retired.has(childId);
|
|
583
|
+
});
|
|
471
584
|
}
|
|
472
585
|
/**
|
|
473
586
|
* Snapshot the real driver activity for durable member ids.
|
package/lib/quality-gates.js
CHANGED
|
@@ -600,6 +600,51 @@ export function isCommandResult(value) {
|
|
|
600
600
|
&& (value['exitCode'] === undefined || (Number.isSafeInteger(value['exitCode'])))
|
|
601
601
|
&& (value['evidence'] === undefined || typeof value['evidence'] === 'string');
|
|
602
602
|
}
|
|
603
|
+
// Optional fields whose persisted values must be non-empty when present
|
|
604
|
+
// (mirrors the checks in hasValidQualityTaskFields). Some models materialize
|
|
605
|
+
// optional tool parameters as "" instead of omitting them (e.g. GPT-5.6
|
|
606
|
+
// sending reviewedTaskId:"" or profile:""), which would otherwise be written
|
|
607
|
+
// to team.json and then brick the whole team state on reload.
|
|
608
|
+
const BLANK_SENSITIVE_STRING_FIELDS = ['objective', 'reviewedTaskId', 'sourceTaskId'];
|
|
609
|
+
const BLANK_SENSITIVE_STRING_LIST_FIELDS = [
|
|
610
|
+
'inScope',
|
|
611
|
+
'outOfScope',
|
|
612
|
+
'acceptance',
|
|
613
|
+
'verify',
|
|
614
|
+
'deliverables',
|
|
615
|
+
'nonGoals',
|
|
616
|
+
'changedPaths',
|
|
617
|
+
'sourceFindingIds',
|
|
618
|
+
'coverageOf',
|
|
619
|
+
];
|
|
620
|
+
/**
|
|
621
|
+
* Normalize blank optional task fields to omitted ("blank means absent").
|
|
622
|
+
* Blank string scalars are deleted; string lists have blank entries filtered
|
|
623
|
+
* out, and a list that only contained blanks is omitted entirely. Non-blank
|
|
624
|
+
* values and every other field are passed through untouched, so durable-state
|
|
625
|
+
* validation stays strict.
|
|
626
|
+
*/
|
|
627
|
+
export function normalizeBlankOptionalTaskFields(task) {
|
|
628
|
+
const next = { ...task };
|
|
629
|
+
for (const key of BLANK_SENSITIVE_STRING_FIELDS) {
|
|
630
|
+
const value = next[key];
|
|
631
|
+
if (typeof value === 'string' && value.trim() === '')
|
|
632
|
+
delete next[key];
|
|
633
|
+
}
|
|
634
|
+
for (const key of BLANK_SENSITIVE_STRING_LIST_FIELDS) {
|
|
635
|
+
const value = next[key];
|
|
636
|
+
if (!Array.isArray(value))
|
|
637
|
+
continue;
|
|
638
|
+
const kept = value.filter((item) => !(typeof item === 'string' && item.trim() === ''));
|
|
639
|
+
if (kept.length === value.length)
|
|
640
|
+
continue;
|
|
641
|
+
if (kept.length === 0)
|
|
642
|
+
delete next[key];
|
|
643
|
+
else
|
|
644
|
+
next[key] = kept;
|
|
645
|
+
}
|
|
646
|
+
return next;
|
|
647
|
+
}
|
|
603
648
|
export function hasValidQualityTaskFields(value) {
|
|
604
649
|
if (value['kind'] !== undefined && !TASK_KINDS.includes(value['kind']))
|
|
605
650
|
return false;
|