@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
package/lib/scheduler.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* the selected durable member. A resident member that becomes idle while it
|
|
9
9
|
* still owns an open attempt is parked: only an explicit captain reassignment
|
|
10
10
|
* may rotate that capability. Automatic retry is reserved for cold recovery,
|
|
11
|
-
* when
|
|
11
|
+
* when this process has not observed the durable owner settle its open attempt.
|
|
12
12
|
* @module dsh-agent-teams/scheduler
|
|
13
13
|
*/
|
|
14
14
|
import { join } from 'node:path';
|
|
@@ -251,13 +251,11 @@ export function installTeamScheduler(ctx, config) {
|
|
|
251
251
|
if (currentMember === undefined || currentMember.id === '' || !isMemberAvailable(ctx, currentMember))
|
|
252
252
|
return undefined;
|
|
253
253
|
const owned = ownedOpenTask(fresh.tasks, currentMember.name);
|
|
254
|
-
//
|
|
255
|
-
//
|
|
256
|
-
//
|
|
257
|
-
//
|
|
258
|
-
//
|
|
259
|
-
// durable capability (cold process recovery) or a legacy open task
|
|
260
|
-
// with no capability is retried.
|
|
254
|
+
// An idle edge observed by this scheduler parks the exact open
|
|
255
|
+
// capability. Harness may dispose its AgentHandle after settlement,
|
|
256
|
+
// so registry absence is not evidence that the owner was lost. Keep
|
|
257
|
+
// the marker sticky across later kicks; only a durable attempt that
|
|
258
|
+
// this process has not observed is eligible for one cold recovery.
|
|
261
259
|
const parkedAttemptId = parkedAttempts.get(currentMember.id);
|
|
262
260
|
const recoverOwned = owned !== undefined
|
|
263
261
|
&& (owned.attemptId === undefined || owned.attemptId !== parkedAttemptId);
|
|
@@ -272,8 +270,18 @@ export function installTeamScheduler(ctx, config) {
|
|
|
272
270
|
return undefined;
|
|
273
271
|
}
|
|
274
272
|
const previousAssignee = task.assignee;
|
|
273
|
+
const previousStatus = recoverOwned ? task.status : undefined;
|
|
274
|
+
const previousAttempt = recoverOwned ? task.attempt : undefined;
|
|
275
|
+
const previousAttemptId = recoverOwned ? task.attemptId : undefined;
|
|
275
276
|
const attemptId = beginTaskAttempt(task, currentMember.name);
|
|
276
|
-
|
|
277
|
+
// A recovered generation is parked before delivery. This makes each
|
|
278
|
+
// (member, attempt) recovery idempotent even if every status poll
|
|
279
|
+
// sees a disposed handle. Fresh pending work remains unparked so a
|
|
280
|
+
// genuinely lost first delivery can be recovered once.
|
|
281
|
+
if (recoverOwned)
|
|
282
|
+
parkedAttempts.set(currentMember.id, attemptId);
|
|
283
|
+
else
|
|
284
|
+
parkedAttempts.delete(currentMember.id);
|
|
277
285
|
currentMember.status = 'working';
|
|
278
286
|
await writeTeam(stateRoot, fresh);
|
|
279
287
|
const profileSeedId = taskProfileSeedId(task);
|
|
@@ -285,6 +293,10 @@ export function installTeamScheduler(ctx, config) {
|
|
|
285
293
|
attempt: task.attempt ?? 1,
|
|
286
294
|
attemptId,
|
|
287
295
|
previousAssignee,
|
|
296
|
+
recoveredOwned: recoverOwned,
|
|
297
|
+
...previousStatus === undefined ? {} : { previousStatus },
|
|
298
|
+
...previousAttempt === undefined ? {} : { previousAttempt },
|
|
299
|
+
...previousAttemptId === undefined ? {} : { previousAttemptId },
|
|
288
300
|
subject: task.subject,
|
|
289
301
|
description: task.description,
|
|
290
302
|
teamDescription: fresh.description,
|
|
@@ -318,9 +330,22 @@ export function installTeamScheduler(ctx, config) {
|
|
|
318
330
|
const task = fresh.tasks.find(candidate => candidate.id === ticket.taskId);
|
|
319
331
|
if (task?.attemptId !== ticket.attemptId)
|
|
320
332
|
return;
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
333
|
+
if (ticket.recoveredOwned && ticket.previousStatus !== undefined && ticket.previousAttemptId !== undefined) {
|
|
334
|
+
// Recovery delivery failed. Restore the durable generation instead
|
|
335
|
+
// of returning it to pending, then keep it parked so later status
|
|
336
|
+
// kicks cannot spend an unbounded sequence of fresh attempts.
|
|
337
|
+
task.status = ticket.previousStatus;
|
|
338
|
+
task.assignee = ticket.previousAssignee;
|
|
339
|
+
task.attempt = ticket.previousAttempt;
|
|
340
|
+
task.attemptId = ticket.previousAttemptId;
|
|
341
|
+
parkedAttempts.set(ticket.memberId, ticket.previousAttemptId);
|
|
342
|
+
}
|
|
343
|
+
else {
|
|
344
|
+
task.status = 'pending';
|
|
345
|
+
task.assignee = ticket.previousAssignee;
|
|
346
|
+
task.attemptId = undefined;
|
|
347
|
+
parkedAttempts.delete(ticket.memberId);
|
|
348
|
+
}
|
|
324
349
|
task.handoffId = undefined;
|
|
325
350
|
task.reassigning = false;
|
|
326
351
|
task.updatedAt = Date.now();
|
package/lib/state.js
CHANGED
|
@@ -17,8 +17,8 @@ import { readFileSync } from 'node:fs';
|
|
|
17
17
|
import { mkdir, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises';
|
|
18
18
|
import { join } from 'node:path';
|
|
19
19
|
import { TERMINAL_TASK_STATUSES } from "./types.js";
|
|
20
|
-
import { hasValidQualityTaskFields, isReviewPolicy } from "./quality-gates.js";
|
|
21
|
-
export { buildCoverageMatrix, canDeclareDelivery, classifyChangedPath, collectChangedPaths, defaultQualityDeliveryGraph, describeQualityLoop, evaluateQualityCompletion, hasValidQualityTaskFields, isQualityKind, pathMatchesScope, planQualityFollowUp, qualityPlanningPrompt, resumeTeamState, sanitizeReviewAcceptance, sanitizeReviewObjective, taskKindOf, validateCreateTask, } from "./quality-gates.js";
|
|
20
|
+
import { hasValidQualityTaskFields, isReviewPolicy, normalizeBlankOptionalTaskFields } from "./quality-gates.js";
|
|
21
|
+
export { buildCoverageMatrix, canDeclareDelivery, classifyChangedPath, collectChangedPaths, defaultQualityDeliveryGraph, describeQualityLoop, evaluateQualityCompletion, hasValidQualityTaskFields, isQualityKind, normalizeBlankOptionalTaskFields, pathMatchesScope, planQualityFollowUp, qualityPlanningPrompt, resumeTeamState, sanitizeReviewAcceptance, sanitizeReviewObjective, taskKindOf, validateCreateTask, } from "./quality-gates.js";
|
|
22
22
|
/** Mailbox key of the captain. */
|
|
23
23
|
export const CAPTAIN_KEY = 'captain';
|
|
24
24
|
/** A crashed live-delivery attempt becomes retryable after this interval. */
|
|
@@ -626,12 +626,17 @@ function coerceTeamState(value, expectedId) {
|
|
|
626
626
|
const tasks = value['tasks'].map((task) => {
|
|
627
627
|
if (!isRecord(task))
|
|
628
628
|
return task;
|
|
629
|
-
|
|
630
|
-
|
|
629
|
+
// Tolerate legacy dirty records instead of bricking the whole team on
|
|
630
|
+
// reload: blank optional fields written by older builds (or by models that
|
|
631
|
+
// materialize optionals as "") are normalized to omitted, matching the
|
|
632
|
+
// profileSeedId handling below and the tool-input normalization.
|
|
633
|
+
const cleaned = normalizeBlankOptionalTaskFields(task);
|
|
634
|
+
if (cleaned['profileSeedId'] !== undefined && (typeof cleaned['profileSeedId'] !== 'string' || cleaned['profileSeedId'].trim() === '')) {
|
|
635
|
+
const next = { ...cleaned };
|
|
631
636
|
delete next['profileSeedId'];
|
|
632
637
|
return next;
|
|
633
638
|
}
|
|
634
|
-
return
|
|
639
|
+
return cleaned;
|
|
635
640
|
});
|
|
636
641
|
const coerced = { ...value, tasks };
|
|
637
642
|
return isTeamState(coerced, expectedId) ? coerced : undefined;
|
package/lib/tools.js
CHANGED
|
@@ -12,11 +12,12 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm';
|
|
|
12
12
|
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
13
13
|
import { join } from 'node:path';
|
|
14
14
|
import { appendTeamEvent, captainSessionOf } from "./events.js";
|
|
15
|
-
import { acknowledgeMailbox, appendMailbox, archiveTeamDir, beginTaskAttempt, CAPTAIN_KEY, createMessage, createTeamDir, findTeamByCaptain, findTeamByParticipant, cancelUnfinishedTask, invalidateTaskAttempt, readUnreadMailbox, recordRetiredMemberIds, releaseMailboxDelivery, readTeam, sanitizeKey, transitionError, unsatisfiedDependencies, withTeamLock, writeTeam, removeTeamDir, validateCreateTask, evaluateQualityCompletion, planQualityFollowUp, resumeTeamState, buildCoverageMatrix, canDeclareDelivery, describeQualityLoop, sanitizeReviewAcceptance, sanitizeReviewObjective, taskKindOf, } from "./state.js";
|
|
16
|
-
import { deliverToMember, installRetiredMemberGuard, installMemberSelectionRuntime, interruptMember, memberActivity, resolveMemberLlmSelection, spawnMember, validateMemberLlmSelections, } from "./members.js";
|
|
15
|
+
import { acknowledgeMailbox, appendMailbox, archiveTeamDir, beginTaskAttempt, CAPTAIN_KEY, createMessage, createTeamDir, findTeamByCaptain, findTeamByParticipant, cancelUnfinishedTask, invalidateTaskAttempt, readUnreadMailbox, recordRetiredMemberIds, releaseMailboxDelivery, readTeam, sanitizeKey, transitionError, unsatisfiedDependencies, withTeamLock, writeTeam, removeTeamDir, validateCreateTask, evaluateQualityCompletion, planQualityFollowUp, resumeTeamState, buildCoverageMatrix, canDeclareDelivery, describeQualityLoop, sanitizeReviewAcceptance, sanitizeReviewObjective, normalizeBlankOptionalTaskFields, taskKindOf, } from "./state.js";
|
|
16
|
+
import { deliverToMember, installRetiredMemberGuard, installMemberSelectionRuntime, interruptMember, memberActivity, resolveMemberLlmSelection, spawnMember, steerCaptainReport, validateMemberLlmSelections, } from "./members.js";
|
|
17
17
|
import { TERMINAL_TASK_STATUSES } from "./types.js";
|
|
18
18
|
import { installTeamScheduler } from "./scheduler.js";
|
|
19
19
|
import { resolveTeamProfile } from "./profiles.js";
|
|
20
|
+
export { steerCaptainReport } from "./members.js";
|
|
20
21
|
/** The caller agent, or a loud failure for non-agent callers. */
|
|
21
22
|
function requireCaptain(exec) {
|
|
22
23
|
if (!exec.agent) {
|
|
@@ -275,19 +276,6 @@ export async function haltTeamWork(input) {
|
|
|
275
276
|
alreadyHalted: halted.alreadyHalted,
|
|
276
277
|
};
|
|
277
278
|
}
|
|
278
|
-
export function steerCaptainReport(captain, from, content) {
|
|
279
|
-
try {
|
|
280
|
-
captain.steer(createUserMessage({
|
|
281
|
-
content: [{ type: 'text', text: `AgentTeams message from member ${from}:\n\n${content}` }],
|
|
282
|
-
source: { kind: 'plugin', plugin: 'dsh-agent-teams' },
|
|
283
|
-
}));
|
|
284
|
-
return true;
|
|
285
|
-
}
|
|
286
|
-
catch {
|
|
287
|
-
// The plugin mailbox was persisted before this best-effort live delivery.
|
|
288
|
-
return false;
|
|
289
|
-
}
|
|
290
|
-
}
|
|
291
279
|
/** Context queued after the human rejects a staged plan. */
|
|
292
280
|
export function stagedPlanDiscardContext(teamName) {
|
|
293
281
|
return [
|
|
@@ -313,8 +301,8 @@ export function stagedPlanFeedbackContext(teamName) {
|
|
|
313
301
|
*/
|
|
314
302
|
export function registerAgentTeamsTools(ctx, config) {
|
|
315
303
|
installRetiredMemberGuard(ctx, config.stateDir);
|
|
316
|
-
const memberSelections = installMemberSelectionRuntime(ctx, config.stateDir);
|
|
317
304
|
const scheduler = installTeamScheduler(ctx, { stateDir: config.stateDir, executionPrompt: config.executionPrompt });
|
|
305
|
+
const memberSelections = installMemberSelectionRuntime(ctx, config.stateDir, (workspace, teamId, memberName) => (scheduler.kickMember(workspace, teamId, memberName)));
|
|
318
306
|
const updateStagedPlanBatch = async (captain, teamId, mutations, signal) => {
|
|
319
307
|
if (mutations.length === 0)
|
|
320
308
|
throw new Error('at least one staged plan operation is required');
|
|
@@ -586,10 +574,12 @@ export function registerAgentTeamsTools(ctx, config) {
|
|
|
586
574
|
throw new Error('team name must not be empty');
|
|
587
575
|
const teamId = sanitizeKey(teamName);
|
|
588
576
|
const staged = args.approval === 'required';
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
577
|
+
// Some models materialize optional parameters as "" instead of omitting
|
|
578
|
+
// them (issue #99). The profile is optional, so treat a blank value
|
|
579
|
+
// exactly like an omitted one instead of failing every create call.
|
|
580
|
+
const profileName = args.profile !== undefined && args.profile.trim() !== ''
|
|
581
|
+
? args.profile.trim()
|
|
582
|
+
: undefined;
|
|
593
583
|
const created = await withTeamLock(captainLockKey(stateRoot, captain.id), async () => {
|
|
594
584
|
const current = await findTeamByParticipant(stateRoot, captain.id);
|
|
595
585
|
if (current !== undefined) {
|
|
@@ -927,6 +917,7 @@ export function registerAgentTeamsTools(ctx, config) {
|
|
|
927
917
|
provider: selection.provider,
|
|
928
918
|
model: selection.model,
|
|
929
919
|
reasoningEffort: selection.reasoningEffort,
|
|
920
|
+
...selection.fallback === undefined ? {} : { fallback: selection.fallback },
|
|
930
921
|
executionPrompt: trimmedOptional(args.executionPrompt),
|
|
931
922
|
joinedAt: Date.now(),
|
|
932
923
|
status: 'idle',
|
|
@@ -1081,28 +1072,33 @@ export function registerAgentTeamsTools(ctx, config) {
|
|
|
1081
1072
|
const workspace = workspaceOf(captain);
|
|
1082
1073
|
const stateRoot = stateRootOf(workspace, config);
|
|
1083
1074
|
const team = await requireCaptainTeam(workspace, config, captain);
|
|
1075
|
+
// Some models materialize optional parameters as "" instead of omitting
|
|
1076
|
+
// them (issue #105). Normalize blank optional fields to omitted before
|
|
1077
|
+
// validation so a blank value can neither be rejected spuriously nor be
|
|
1078
|
+
// persisted into team.json, where it would brick the team on reload.
|
|
1079
|
+
const input = normalizeBlankOptionalTaskFields(args);
|
|
1084
1080
|
const created = await withTeamLock(teamLockKey(stateRoot, team.id), async () => {
|
|
1085
1081
|
const fresh = await requireFreshCaptainTeam(stateRoot, team.id, captain.id);
|
|
1086
1082
|
const gate = validateCreateTask(fresh, {
|
|
1087
|
-
subject:
|
|
1088
|
-
description:
|
|
1089
|
-
dependencies:
|
|
1090
|
-
assignee:
|
|
1091
|
-
kind:
|
|
1092
|
-
round:
|
|
1093
|
-
objective:
|
|
1094
|
-
inScope:
|
|
1095
|
-
outOfScope:
|
|
1096
|
-
acceptance:
|
|
1097
|
-
verify:
|
|
1098
|
-
deliverables:
|
|
1099
|
-
nonGoals:
|
|
1100
|
-
reviewedTaskId:
|
|
1101
|
-
sourceTaskId:
|
|
1102
|
-
sourceFindingIds:
|
|
1103
|
-
coverageOf:
|
|
1104
|
-
resume:
|
|
1105
|
-
resumeReason:
|
|
1083
|
+
subject: input.subject,
|
|
1084
|
+
description: input.description,
|
|
1085
|
+
dependencies: input.dependencies,
|
|
1086
|
+
assignee: input.assignee,
|
|
1087
|
+
kind: input.kind,
|
|
1088
|
+
round: input.round,
|
|
1089
|
+
objective: input.objective,
|
|
1090
|
+
inScope: input.inScope,
|
|
1091
|
+
outOfScope: input.outOfScope,
|
|
1092
|
+
acceptance: input.acceptance,
|
|
1093
|
+
verify: input.verify,
|
|
1094
|
+
deliverables: input.deliverables,
|
|
1095
|
+
nonGoals: input.nonGoals,
|
|
1096
|
+
reviewedTaskId: input.reviewedTaskId,
|
|
1097
|
+
sourceTaskId: input.sourceTaskId,
|
|
1098
|
+
sourceFindingIds: input.sourceFindingIds,
|
|
1099
|
+
coverageOf: input.coverageOf,
|
|
1100
|
+
resume: input.resume,
|
|
1101
|
+
resumeReason: input.resumeReason,
|
|
1106
1102
|
});
|
|
1107
1103
|
if (!gate.ok)
|
|
1108
1104
|
throw new Error(gate.error ?? 'create_task rejected by quality gates');
|
|
@@ -1128,11 +1124,11 @@ export function registerAgentTeamsTools(ctx, config) {
|
|
|
1128
1124
|
requireMember(fresh, args.assignee);
|
|
1129
1125
|
const kind = gate.kind ?? 'work';
|
|
1130
1126
|
const objective = kind === 'review' || kind === 'requirements'
|
|
1131
|
-
? sanitizeReviewObjective(
|
|
1132
|
-
:
|
|
1127
|
+
? sanitizeReviewObjective(input.objective)
|
|
1128
|
+
: input.objective;
|
|
1133
1129
|
const acceptance = kind === 'review' || kind === 'requirements'
|
|
1134
|
-
? sanitizeReviewAcceptance(
|
|
1135
|
-
:
|
|
1130
|
+
? sanitizeReviewAcceptance(input.acceptance)
|
|
1131
|
+
: input.acceptance;
|
|
1136
1132
|
const task = {
|
|
1137
1133
|
id: `t${fresh.taskSeq + 1}`,
|
|
1138
1134
|
subject: args.subject,
|
|
@@ -1146,16 +1142,16 @@ export function registerAgentTeamsTools(ctx, config) {
|
|
|
1146
1142
|
kind,
|
|
1147
1143
|
...args.round === undefined ? {} : { round: args.round },
|
|
1148
1144
|
...objective === undefined ? {} : { objective },
|
|
1149
|
-
...
|
|
1150
|
-
...
|
|
1145
|
+
...input.inScope === undefined ? {} : { inScope: input.inScope },
|
|
1146
|
+
...input.outOfScope === undefined ? {} : { outOfScope: input.outOfScope },
|
|
1151
1147
|
...acceptance === undefined ? {} : { acceptance },
|
|
1152
|
-
...
|
|
1153
|
-
...
|
|
1154
|
-
...
|
|
1155
|
-
...
|
|
1156
|
-
...
|
|
1157
|
-
...
|
|
1158
|
-
...
|
|
1148
|
+
...input.verify === undefined ? {} : { verify: input.verify },
|
|
1149
|
+
...input.deliverables === undefined ? {} : { deliverables: input.deliverables },
|
|
1150
|
+
...input.nonGoals === undefined ? {} : { nonGoals: input.nonGoals },
|
|
1151
|
+
...input.reviewedTaskId === undefined ? {} : { reviewedTaskId: input.reviewedTaskId },
|
|
1152
|
+
...input.sourceTaskId === undefined ? {} : { sourceTaskId: input.sourceTaskId },
|
|
1153
|
+
...input.sourceFindingIds === undefined ? {} : { sourceFindingIds: input.sourceFindingIds },
|
|
1154
|
+
...input.coverageOf === undefined ? {} : { coverageOf: input.coverageOf },
|
|
1159
1155
|
};
|
|
1160
1156
|
fresh.taskSeq += 1;
|
|
1161
1157
|
fresh.tasks.push(task);
|
|
@@ -1304,10 +1300,10 @@ export function registerAgentTeamsTools(ctx, config) {
|
|
|
1304
1300
|
}));
|
|
1305
1301
|
ctx.tools.register(defineTool({
|
|
1306
1302
|
name: 'agent_teams_claim_task',
|
|
1307
|
-
description: '
|
|
1303
|
+
description: 'Members claim their own ready task or read their existing attempt_id. Captains must use reassign_task to assign and wake a member; claim_task does not dispatch work. A member cannot own a second unfinished task. The returned attempt_id is required for updates and becomes stale after retry/reassignment.',
|
|
1308
1304
|
parameters: {
|
|
1309
1305
|
task_id: { type: 'string', required: true, description: 'The task id to claim.' },
|
|
1310
|
-
assignee: { type: 'string', description: '
|
|
1306
|
+
assignee: { type: 'string', description: 'Deprecated: claim_task only supports a member claiming its own task. Captains must use reassign_task.' },
|
|
1311
1307
|
},
|
|
1312
1308
|
output: {
|
|
1313
1309
|
schema: {
|
|
@@ -1339,9 +1335,11 @@ export function registerAgentTeamsTools(ctx, config) {
|
|
|
1339
1335
|
}
|
|
1340
1336
|
let assignee = task.assignee;
|
|
1341
1337
|
if (identity.kind === 'captain') {
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1338
|
+
// A captain may read the capability of its already-started takeover,
|
|
1339
|
+
// but must never create a member claim without dispatching it (#125).
|
|
1340
|
+
if (args.assignee !== undefined || task.assignee !== CAPTAIN_KEY
|
|
1341
|
+
|| (task.status !== 'claimed' && task.status !== 'in_progress')) {
|
|
1342
|
+
throw new Error('claim_task is for members claiming their own task; captains must use agent_teams_reassign_task to assign and wake a member');
|
|
1345
1343
|
}
|
|
1346
1344
|
}
|
|
1347
1345
|
else {
|
|
@@ -1518,6 +1516,10 @@ export function registerAgentTeamsTools(ctx, config) {
|
|
|
1518
1516
|
...task.output !== undefined ? { output: task.output } : {},
|
|
1519
1517
|
};
|
|
1520
1518
|
}
|
|
1519
|
+
// Blank optional list entries (e.g. changedPaths:[""]) must not be
|
|
1520
|
+
// persisted: hasValidQualityTaskFields rejects them on reload and
|
|
1521
|
+
// would brick the whole team state (issue #105 class).
|
|
1522
|
+
const input = normalizeBlankOptionalTaskFields(args);
|
|
1521
1523
|
const findings = parseFindings(args.findings);
|
|
1522
1524
|
const acceptanceResults = parseAcceptanceResults(args.acceptanceResults);
|
|
1523
1525
|
const commandsRun = parseCommandResults(args.commandsRun);
|
|
@@ -1526,7 +1528,7 @@ export function registerAgentTeamsTools(ctx, config) {
|
|
|
1526
1528
|
output: args.output,
|
|
1527
1529
|
verdict: args.verdict,
|
|
1528
1530
|
findings,
|
|
1529
|
-
changedPaths:
|
|
1531
|
+
changedPaths: input.changedPaths,
|
|
1530
1532
|
acceptanceResults,
|
|
1531
1533
|
commandsRun,
|
|
1532
1534
|
});
|
|
@@ -1544,8 +1546,8 @@ export function registerAgentTeamsTools(ctx, config) {
|
|
|
1544
1546
|
task.verdict = args.verdict;
|
|
1545
1547
|
if (findings !== undefined)
|
|
1546
1548
|
task.findings = findings;
|
|
1547
|
-
if (
|
|
1548
|
-
task.changedPaths =
|
|
1549
|
+
if (input.changedPaths !== undefined)
|
|
1550
|
+
task.changedPaths = input.changedPaths;
|
|
1549
1551
|
if (acceptanceResults !== undefined)
|
|
1550
1552
|
task.acceptanceResults = acceptanceResults;
|
|
1551
1553
|
if (commandsRun !== undefined)
|
|
@@ -1698,7 +1700,7 @@ export function registerAgentTeamsTools(ctx, config) {
|
|
|
1698
1700
|
}));
|
|
1699
1701
|
ctx.tools.register(defineTool({
|
|
1700
1702
|
name: 'agent_teams_status',
|
|
1701
|
-
description: 'Team snapshot: members with live activity and tasks with status/assignee/dependencies/output. Captains also see every team mailbox; members see only their own inbox.
|
|
1703
|
+
description: 'Team snapshot: members with live activity and tasks with status/assignee/dependencies/output. Captains also see every team mailbox; members see only their own inbox. Use after mailbox progress deliveries or for an explicit status request. After dispatch, end your turn while members work; do not repeatedly poll.',
|
|
1702
1704
|
parameters: {},
|
|
1703
1705
|
output: {
|
|
1704
1706
|
schema: { type: 'object', additionalProperties: true, properties: {} },
|
|
@@ -2065,7 +2067,9 @@ function parseFindings(value) {
|
|
|
2065
2067
|
severity: raw['severity'],
|
|
2066
2068
|
problem: raw['problem'],
|
|
2067
2069
|
requiredFix: raw['requiredFix'],
|
|
2068
|
-
|
|
2070
|
+
// A blank optional file must be omitted, not persisted: durable-state
|
|
2071
|
+
// validation requires non-empty optional strings (issue #105 class).
|
|
2072
|
+
...typeof raw['file'] === 'string' && raw['file'].trim() !== '' ? { file: raw['file'] } : {},
|
|
2069
2073
|
...typeof raw['line'] === 'number' ? { line: raw['line'] } : {},
|
|
2070
2074
|
...typeof raw['resolved'] === 'boolean' ? { resolved: raw['resolved'] } : {},
|
|
2071
2075
|
};
|
|
@@ -81,7 +81,6 @@ export declare const zh: {
|
|
|
81
81
|
'dependency.hint.pinned': string;
|
|
82
82
|
'task.runningAria': string;
|
|
83
83
|
'task.model': string;
|
|
84
|
-
'member.model': string;
|
|
85
84
|
'task.detail.completed': string;
|
|
86
85
|
'task.detail.noPrerequisite': string;
|
|
87
86
|
'task.detail.ready': string;
|
|
@@ -275,7 +274,6 @@ export declare const en: {
|
|
|
275
274
|
'dependency.hint.pinned': string;
|
|
276
275
|
'task.runningAria': string;
|
|
277
276
|
'task.model': string;
|
|
278
|
-
'member.model': string;
|
|
279
277
|
'task.detail.completed': string;
|
|
280
278
|
'task.detail.noPrerequisite': string;
|
|
281
279
|
'task.detail.ready': string;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The audited Harness 0.1.2 subagent boundary. Keep version-specific shapes
|
|
3
|
+
* here: API presence alone is not a promise of support for future versions.
|
|
4
|
+
*
|
|
5
|
+
* Alpha.2 owns followup/registerContinuableSetup; Alpha.5 and rc.1 own a
|
|
6
|
+
* host-only FIFO queue and synchronous agent/session-start. Their public
|
|
7
|
+
* sendMessage instead steers a running Agent and must never carry team jobs.
|
|
8
|
+
*/
|
|
9
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
10
|
+
import type { Agent } from '@deepseek-ai/dsh-agent';
|
|
11
|
+
import type { ContentBlock, MessageId } from '@deepseek-ai/dsh-llm';
|
|
12
|
+
import type { Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session';
|
|
13
|
+
type Setup = (childCtx: Context) => () => void;
|
|
14
|
+
/** Read child-owned history, excluding any descriptor inherited from a parent. */
|
|
15
|
+
export declare function sessionOwnEvents(session: Session): readonly SessionEvent[];
|
|
16
|
+
/** Install before the first request, including cold resume, with HMR cleanup. */
|
|
17
|
+
export declare function installContinuableMemberSetup(ctx: Context, setup: Setup): void;
|
|
18
|
+
/** Queue a distinct host-authored turn; never substitute model-message steer. */
|
|
19
|
+
export declare function queueMemberPrompt(runtime: Context['subagents'], parent: Agent, childId: SessionId, content: ContentBlock[], signal: AbortSignal): Promise<MessageId>;
|
|
20
|
+
/** Guard all resumable delivery paths, preserving the native service receiver. */
|
|
21
|
+
export declare function guardSubagentDelivery(ctx: Context, isRetired: (sender: Agent, targetId: SessionId) => Promise<boolean>): void;
|
|
22
|
+
export {};
|
package/lib/types/members.d.ts
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,7 +13,8 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import type { Context } from '@deepseek-ai/cordis';
|
|
15
15
|
import { type Agent } from '@deepseek-ai/dsh-agent';
|
|
16
|
-
import
|
|
16
|
+
import type { Session } from '@deepseek-ai/dsh-session';
|
|
17
|
+
import { type TeamMember, type TeamState, type TeamTask } from './types.ts';
|
|
17
18
|
/** Persona snapshot of a profile protocol; the full text lives on team.json. */
|
|
18
19
|
export declare const PERSONA_PROTOCOL_MAX_CHARS = 400;
|
|
19
20
|
/** Runtime knobs for member spawning, resolved from plugin config. */
|
|
@@ -88,6 +89,18 @@ export declare function selectFallbackRoute(current: {
|
|
|
88
89
|
model: string;
|
|
89
90
|
};
|
|
90
91
|
};
|
|
92
|
+
/** Deliver a durable member report to the live captain at its next model step. */
|
|
93
|
+
export declare function steerCaptainReport(captain: Pick<Agent, 'steer'>, from: string, content: string): boolean;
|
|
94
|
+
interface FailedMemberAttempt {
|
|
95
|
+
readonly captainSessionId: string;
|
|
96
|
+
readonly memberId: string;
|
|
97
|
+
readonly task?: Pick<TeamTask, 'id' | 'attempt' | 'attemptId'>;
|
|
98
|
+
}
|
|
99
|
+
/** Record a final turn failure, never an intermediate request retry. */
|
|
100
|
+
export declare function failMemberOpenAttempt(ctx: Context, stateRoot: string, teamId: string, memberName: string, failure: {
|
|
101
|
+
readonly code: string;
|
|
102
|
+
readonly message: string;
|
|
103
|
+
}, fallbackSession: Session, observed: FailedMemberAttempt): Promise<boolean>;
|
|
91
104
|
/**
|
|
92
105
|
* Resolve one member's complete model selection. Ordinary members snapshot the
|
|
93
106
|
* captain's current request route and reasoning effort. When provider or model
|
|
@@ -105,7 +118,7 @@ export declare function resolveMemberLlmSelection(ctx: Context, captain: Agent,
|
|
|
105
118
|
* record. Legacy members without a complete saved route retain Harness's
|
|
106
119
|
* descriptor provider/model behavior.
|
|
107
120
|
*/
|
|
108
|
-
export declare function installMemberSelectionRuntime(ctx: Context, stateDir: string): MemberSelectionRuntime;
|
|
121
|
+
export declare function installMemberSelectionRuntime(ctx: Context, stateDir: string, onFailureSettled?: (workspace: string, teamId: string, memberName: string) => Promise<void>): MemberSelectionRuntime;
|
|
109
122
|
/**
|
|
110
123
|
* The member's system prompt (persona), shadowing the deployment persona for
|
|
111
124
|
* that child. Self-contained: it replaces the whole persona section.
|
|
@@ -168,12 +181,12 @@ export declare function interruptMember(ctx: Context, captain: Agent, childId: s
|
|
|
168
181
|
*
|
|
169
182
|
* Upstream `interrupt()` deliberately preserves continuable sessions and the
|
|
170
183
|
* upstream seam exposes no targeted forget/retire method. The durable
|
|
171
|
-
* AgentTeams index therefore rejects
|
|
184
|
+
* AgentTeams index therefore rejects every inbox delivery before it can cold-resume a
|
|
172
185
|
* retired member. Catalog rows deliberately remain discoverable: Harness rc.8
|
|
173
186
|
* uses the direct-child catalog to authorize historical transcript reads and
|
|
174
187
|
* `openSubagent()`, so filtering those rows would make an archived member's
|
|
175
188
|
* persisted conversation inaccessible. Exact ids keep unrelated subagents
|
|
176
|
-
* untouched while the
|
|
189
|
+
* untouched while the delivery boundary still prevents further model turns.
|
|
177
190
|
*/
|
|
178
191
|
export declare function installRetiredMemberGuard(ctx: Context, stateDir: string): void;
|
|
179
192
|
/**
|
|
@@ -189,3 +202,4 @@ export declare function installRetiredMemberGuard(ctx: Context, stateDir: string
|
|
|
189
202
|
* @returns child id → live activity.
|
|
190
203
|
*/
|
|
191
204
|
export declare function memberActivity(ctx: Context, memberIds: readonly string[]): Map<string, 'running' | 'idle' | 'ready'>;
|
|
205
|
+
export {};
|
|
@@ -128,6 +128,14 @@ export declare function resumeTeamState(team: TeamState, reason: string): Resume
|
|
|
128
128
|
export declare function isReviewFinding(value: unknown): value is ReviewFinding;
|
|
129
129
|
export declare function isAcceptanceResult(value: unknown): value is AcceptanceResult;
|
|
130
130
|
export declare function isCommandResult(value: unknown): value is CommandResult;
|
|
131
|
+
/**
|
|
132
|
+
* Normalize blank optional task fields to omitted ("blank means absent").
|
|
133
|
+
* Blank string scalars are deleted; string lists have blank entries filtered
|
|
134
|
+
* out, and a list that only contained blanks is omitted entirely. Non-blank
|
|
135
|
+
* values and every other field are passed through untouched, so durable-state
|
|
136
|
+
* validation stays strict.
|
|
137
|
+
*/
|
|
138
|
+
export declare function normalizeBlankOptionalTaskFields<T extends object>(task: T): T;
|
|
131
139
|
export declare function hasValidQualityTaskFields(value: Record<string, unknown>): boolean;
|
|
132
140
|
export declare function isTaskKind(value: unknown): value is TaskKind;
|
|
133
141
|
export declare function isReviewVerdict(value: unknown): value is ReviewVerdict;
|
package/lib/types/scheduler.d.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* the selected durable member. A resident member that becomes idle while it
|
|
9
9
|
* still owns an open attempt is parked: only an explicit captain reassignment
|
|
10
10
|
* may rotate that capability. Automatic retry is reserved for cold recovery,
|
|
11
|
-
* when
|
|
11
|
+
* when this process has not observed the durable owner settle its open attempt.
|
|
12
12
|
* @module dsh-agent-teams/scheduler
|
|
13
13
|
*/
|
|
14
14
|
import type { Context } from '@deepseek-ai/cordis';
|
|
@@ -42,6 +42,12 @@ export interface DispatchTicket {
|
|
|
42
42
|
readonly attempt: number;
|
|
43
43
|
readonly attemptId: string;
|
|
44
44
|
readonly previousAssignee?: string;
|
|
45
|
+
/** True when this ticket rotates an unobserved durable open attempt. */
|
|
46
|
+
readonly recoveredOwned: boolean;
|
|
47
|
+
/** Original task generation, used to restore a failed automatic recovery. */
|
|
48
|
+
readonly previousStatus?: 'claimed' | 'in_progress';
|
|
49
|
+
readonly previousAttempt?: number;
|
|
50
|
+
readonly previousAttemptId?: string;
|
|
45
51
|
readonly subject: string;
|
|
46
52
|
readonly description?: string;
|
|
47
53
|
readonly teamDescription?: string;
|
package/lib/types/state.d.ts
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* @module dsh-agent-teams/state
|
|
14
14
|
*/
|
|
15
15
|
import { type TaskStatus, type TeamMessage, type TeamState, type TeamTask } from './types.ts';
|
|
16
|
-
export { buildCoverageMatrix, canDeclareDelivery, classifyChangedPath, collectChangedPaths, defaultQualityDeliveryGraph, describeQualityLoop, evaluateQualityCompletion, hasValidQualityTaskFields, isQualityKind, pathMatchesScope, planQualityFollowUp, qualityPlanningPrompt, resumeTeamState, sanitizeReviewAcceptance, sanitizeReviewObjective, taskKindOf, validateCreateTask, } from './quality-gates.ts';
|
|
16
|
+
export { buildCoverageMatrix, canDeclareDelivery, classifyChangedPath, collectChangedPaths, defaultQualityDeliveryGraph, describeQualityLoop, evaluateQualityCompletion, hasValidQualityTaskFields, isQualityKind, normalizeBlankOptionalTaskFields, pathMatchesScope, planQualityFollowUp, qualityPlanningPrompt, resumeTeamState, sanitizeReviewAcceptance, sanitizeReviewObjective, taskKindOf, validateCreateTask, } from './quality-gates.ts';
|
|
17
17
|
/** Mailbox key of the captain. */
|
|
18
18
|
export declare const CAPTAIN_KEY = "captain";
|
|
19
19
|
/**
|
package/lib/types/tools.d.ts
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import type { Context } from '@deepseek-ai/cordis';
|
|
12
12
|
import type { Agent } from '@deepseek-ai/dsh-agent';
|
|
13
13
|
import { type TeamState, type TeamTask } from './types.ts';
|
|
14
|
+
export { steerCaptainReport } from './members.ts';
|
|
14
15
|
/** Resolved plugin config consumed by the tools. */
|
|
15
16
|
export interface ToolsConfig {
|
|
16
17
|
/** State directory name under the captain's workspace. */
|
|
@@ -87,7 +88,6 @@ export declare function haltTeamWork(input: {
|
|
|
87
88
|
cancelledTasks: number;
|
|
88
89
|
alreadyHalted: boolean;
|
|
89
90
|
}>;
|
|
90
|
-
export declare function steerCaptainReport(captain: Pick<Agent, 'steer'>, from: string, content: string): boolean;
|
|
91
91
|
/** Context queued after the human rejects a staged plan. */
|
|
92
92
|
export declare function stagedPlanDiscardContext(teamName: string): string;
|
|
93
93
|
/** Model-facing continuation that turns the review UI back into a conversation. */
|
|
@@ -9,5 +9,11 @@ export interface WebRouteHost {
|
|
|
9
9
|
}): () => void;
|
|
10
10
|
}
|
|
11
11
|
export type BrowserRequestGate = Pick<HostConnectionService, 'requestRejection'>;
|
|
12
|
+
export declare class RequestBodyError extends Error {
|
|
13
|
+
readonly status: 400 | 413;
|
|
14
|
+
constructor(message: string, status: 400 | 413);
|
|
15
|
+
}
|
|
16
|
+
/** Bound memory while draining oversized requests so the route can return 413. */
|
|
17
|
+
export declare function readJsonRequest(req: IncomingMessage, maxBytes?: number): Promise<Record<string, unknown>>;
|
|
12
18
|
/** Raw WebServer routes do not inherit Connection's authentication fence. */
|
|
13
19
|
export declare function authenticatedWebRoutes(server: WebRouteHost, connection: () => BrowserRequestGate | undefined): WebRouteHost;
|
package/lib/web-routes.js
CHANGED
|
@@ -1,3 +1,63 @@
|
|
|
1
|
+
export class RequestBodyError extends Error {
|
|
2
|
+
status;
|
|
3
|
+
constructor(message, status) {
|
|
4
|
+
super(message);
|
|
5
|
+
this.status = status;
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
/** Bound memory while draining oversized requests so the route can return 413. */
|
|
9
|
+
export async function readJsonRequest(req, maxBytes = 1_000_000) {
|
|
10
|
+
const raw = await new Promise((resolve, reject) => {
|
|
11
|
+
let size = 0;
|
|
12
|
+
let settled = false;
|
|
13
|
+
const chunks = [];
|
|
14
|
+
const finish = (error) => {
|
|
15
|
+
if (settled)
|
|
16
|
+
return;
|
|
17
|
+
settled = true;
|
|
18
|
+
req.off('data', onData);
|
|
19
|
+
req.off('end', onEnd);
|
|
20
|
+
req.off('aborted', onAborted);
|
|
21
|
+
req.off('error', onError);
|
|
22
|
+
if (error) {
|
|
23
|
+
chunks.length = 0;
|
|
24
|
+
// IncomingMessage may still receive data; discard it without buffering.
|
|
25
|
+
req.once('error', () => { });
|
|
26
|
+
req.resume();
|
|
27
|
+
reject(error);
|
|
28
|
+
}
|
|
29
|
+
else {
|
|
30
|
+
resolve(Buffer.concat(chunks).toString('utf8'));
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
const onData = (chunk) => {
|
|
34
|
+
const part = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
35
|
+
size += part.length;
|
|
36
|
+
if (size > maxBytes)
|
|
37
|
+
finish(new RequestBodyError('request body is too large', 413));
|
|
38
|
+
else
|
|
39
|
+
chunks.push(part);
|
|
40
|
+
};
|
|
41
|
+
const onEnd = () => finish();
|
|
42
|
+
const onAborted = () => finish(new RequestBodyError('request body was aborted', 400));
|
|
43
|
+
const onError = () => finish(new RequestBodyError('invalid request body', 400));
|
|
44
|
+
req.on('data', onData);
|
|
45
|
+
req.once('end', onEnd);
|
|
46
|
+
req.once('aborted', onAborted);
|
|
47
|
+
req.once('error', onError);
|
|
48
|
+
});
|
|
49
|
+
let value;
|
|
50
|
+
try {
|
|
51
|
+
value = raw.trim() === '' ? {} : JSON.parse(raw);
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
throw new RequestBodyError('invalid JSON', 400);
|
|
55
|
+
}
|
|
56
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
57
|
+
throw new RequestBodyError('body must be an object', 400);
|
|
58
|
+
}
|
|
59
|
+
return value;
|
|
60
|
+
}
|
|
1
61
|
/** Raw WebServer routes do not inherit Connection's authentication fence. */
|
|
2
62
|
export function authenticatedWebRoutes(server, connection) {
|
|
3
63
|
return {
|