@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.
@@ -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. Check status after progress notifications 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.
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 = raw.trim() === '' ? {} : JSON.parse(raw);
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 JSON' }));
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
- const chunks = [];
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
- * wakes it with {@link ctx.subagents.followup}, it works through its turn
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, SubagentError } from '@deepseek-ai/dsh-subagent';
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 { readRetiredMemberIds, readTeamSync, readTeam, withTeamLock, writeTeam } from "./state.js";
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
- const reasoningEffort = member.reasoningEffort?.trim();
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.subagents.registerContinuableSetup((childCtx) => {
273
+ installContinuableMemberSetup(ctx, (childCtx) => {
195
274
  const child = childCtx.agent;
196
275
  if (child === undefined)
197
276
  return () => undefined;
198
- const suffix = child.session.events.slice(child.session.header.seedLength ?? 0);
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 identity = descriptor.label.slice(MEMBER_LABEL_PREFIX.length);
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
- // An old team record has no provider/reasoning snapshot. Its durable
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 undefined;
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
- if (!transition.retry)
240
- return undefined;
241
- switched = transition.switched;
242
- selectionRef.current = transition.selection;
243
- const workspace = child.session.header.cwd ?? process.cwd();
244
- const identity = descriptor.label.slice(MEMBER_LABEL_PREFIX.length);
245
- const separator = identity.indexOf(':');
246
- if (separator > 0) {
247
- const teamId = identity.slice(0, separator);
248
- const memberName = identity.slice(separator + 1);
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
- ctx.logger.warn(`agent-teams: member ${child.id} switching to fallback ${fallback.provider}/${fallback.model} after ${payload.failure.code}`);
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.followup(captain, brandedSessionId(childId), [{ type: 'text', text }], {
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: followup to member ${childId} failed: ${String(error)}`);
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 `followup()` before it can cold-resume a
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 followup boundary still prevents further model turns.
577
+ * untouched while the delivery boundary still prevents further model turns.
453
578
  */
454
579
  export function installRetiredMemberGuard(ctx, stateDir) {
455
- const runtime = ctx.subagents;
456
- ctx.effect(() => {
457
- const followup = runtime.followup;
458
- const guardedFollowup = async (parent, childId, content, options) => {
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.
@@ -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;