@nanmicoder/dsh-agent-teams 0.1.22 → 0.1.24-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 +35 -13
- package/README_ZH.md +35 -13
- package/compatibility.json +6 -2
- package/lib/artifacts.js +342 -0
- package/lib/capabilities.js +6 -4
- package/lib/client/ActivityPanel.js +5 -2
- package/lib/client/agent-teams-card-definition.js +100 -6
- package/lib/client.js +90 -7
- package/lib/client.js.map +1 -1
- package/lib/command.js +3 -2
- package/lib/git-artifact-stamp.json +2 -2
- package/lib/harness-compat.js +139 -24
- package/lib/index.js +26 -3
- package/lib/lineage.js +198 -0
- package/lib/mailbox.js +59 -34
- package/lib/members.js +355 -82
- package/lib/profiles.js +3 -2
- package/lib/quality-gates.js +850 -65
- package/lib/scheduler.js +22 -3
- package/lib/snapshot.js +22 -1
- package/lib/state.js +282 -13
- package/lib/tool-policies.js +187 -0
- package/lib/tools.js +382 -109
- package/lib/types/artifacts.d.ts +66 -0
- package/lib/types/client/activity-monitor.d.ts +6 -0
- package/lib/types/client/agent-teams-card-definition.d.ts +56 -0
- package/lib/types/harness-compat.d.ts +78 -15
- package/lib/types/index.d.ts +26 -1
- package/lib/types/lineage.d.ts +92 -0
- package/lib/types/members.d.ts +113 -2
- package/lib/types/profiles.d.ts +6 -0
- package/lib/types/quality-gates.d.ts +216 -10
- package/lib/types/scheduler.d.ts +2 -1
- package/lib/types/snapshot.d.ts +20 -0
- package/lib/types/state.d.ts +22 -5
- package/lib/types/tool-policies.d.ts +119 -0
- package/lib/types/tools.d.ts +12 -1
- package/lib/types/types.d.ts +100 -0
- package/lib/types/workspace-audit.d.ts +27 -0
- package/lib/workspace-audit.js +52 -0
- package/package.json +61 -54
- package/pnpm-workspace.yaml +304 -273
- package/release-notes/v0.1.23.md +34 -0
- package/release-notes/v0.1.24-rc.1.md +13 -0
- package/scripts/legacy-session-source-repair.mjs +344 -0
package/lib/command.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createUserMessage } from '@deepseek-ai/dsh-llm';
|
|
2
|
+
import { agentTeamsSource } from "./harness-compat.js";
|
|
2
3
|
import { parseProfileInvocation, resolveProfileTaskPlanning } from "./profiles.js";
|
|
3
4
|
export const AGENT_TEAMS_COMMAND = 'agent-teams';
|
|
4
5
|
const PROFILE_COMMAND_PREFIX = `${AGENT_TEAMS_COMMAND}-`;
|
|
@@ -125,7 +126,7 @@ export function installAgentTeamsGestureBoundary(ctx, getProfiles = () => ({}))
|
|
|
125
126
|
invocation = invokedAgentTeamsInvocation(messages, getProfiles);
|
|
126
127
|
}
|
|
127
128
|
catch (error) {
|
|
128
|
-
return { kind: 'enter', messages: [...decision.messages, createUserMessage({ content: [{ type: 'text', text: `AgentTeams profile parsing failed: ${String(error)}` }], source: { kind: 'agent-teams-command' } })] };
|
|
129
|
+
return { kind: 'enter', messages: [...decision.messages, createUserMessage({ content: [{ type: 'text', text: `AgentTeams profile parsing failed: ${String(error)}` }], source: agentTeamsSource({ kind: 'agent-teams-command' }) })] };
|
|
129
130
|
}
|
|
130
131
|
if (invocation === undefined)
|
|
131
132
|
return decision;
|
|
@@ -138,6 +139,6 @@ export function installAgentTeamsGestureBoundary(ctx, getProfiles = () => ({}))
|
|
|
138
139
|
const text = !known
|
|
139
140
|
? `AgentTeams profile "${invocation.profile}" does not exist. Available profiles: ${Object.keys(profiles).join(', ') || '(none)'}. Do not create a team.`
|
|
140
141
|
: buildActivationDirective(invocation.goal, invocation.profile, resolveProfileTaskPlanning(matched?.[1]));
|
|
141
|
-
return { kind: 'enter', messages: [...decision.messages, createUserMessage({ content: [{ type: 'text', text }], source: { kind: 'agent-teams-command', ...invocation.goal === '' ? {} : { goal: invocation.goal }, ...invocation.profile === undefined ? {} : { profile: invocation.profile } } })] };
|
|
142
|
+
return { kind: 'enter', messages: [...decision.messages, createUserMessage({ content: [{ type: 'text', text }], source: agentTeamsSource({ kind: 'agent-teams-command', ...invocation.goal === '' ? {} : { goal: invocation.goal }, ...invocation.profile === undefined ? {} : { profile: invocation.profile } }) })] };
|
|
142
143
|
});
|
|
143
144
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schema": 1,
|
|
3
|
-
"source": "
|
|
4
|
-
"output": "
|
|
3
|
+
"source": "983fb392dfa483a3095fd1e7ad67bfd5786d467c77244865d22e7ca7b046e2ad",
|
|
4
|
+
"output": "bb2031aa6d907a929ad81d99ab74f6f2c2136ce1f3a1e9bb68006453bff43908"
|
|
5
5
|
}
|
package/lib/harness-compat.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createUserMessage } from '@deepseek-ai/dsh-llm';
|
|
2
|
+
import * as dshSession from '@deepseek-ai/dsh-session';
|
|
2
3
|
import { SubagentError } from '@deepseek-ai/dsh-subagent';
|
|
3
|
-
import {
|
|
4
|
+
import { BUILTIN_DENY_TOOLS, resolveMemberToolFilter } from "./tool-policies.js";
|
|
4
5
|
/**
|
|
5
6
|
* Exact protocol exported by dsh-subagent/internal in Alpha.5 and rc.1.
|
|
6
7
|
* That subpath does not exist in Alpha.2, so importing it statically prevents
|
|
@@ -12,12 +13,95 @@ const hostPromptQueue = Symbol.for('dsh.subagent.queuePrompt');
|
|
|
12
13
|
// 0.1.5 keeps queueHostSubagentPrompt but replaces its symbol with this
|
|
13
14
|
// delivery-mode-aware implementation (packages/subagent/subagent/src/internal.ts).
|
|
14
15
|
const hostPromptDeliver = Symbol.for('dsh.subagent.deliverPrompt');
|
|
16
|
+
export function usesManagedActivations(runtime) {
|
|
17
|
+
return typeof runtime.startActivation === 'function';
|
|
18
|
+
}
|
|
19
|
+
export async function startMember(runtime, spec) {
|
|
20
|
+
const host = runtime;
|
|
21
|
+
if (typeof host.startActivation === 'function') {
|
|
22
|
+
const { persona: _persona, toolFilter: _filter, ...request } = spec.request;
|
|
23
|
+
const activation = await host.startActivation.call(runtime, { ...spec, request, delivery: 'parent' });
|
|
24
|
+
// Preserve native settlement as the missing-report/error fallback on both
|
|
25
|
+
// fresh and resumed activations. mailbox.ts deduplicates successful reports.
|
|
26
|
+
void activation.result.catch(() => undefined);
|
|
27
|
+
return activation;
|
|
28
|
+
}
|
|
29
|
+
if (typeof host.startContinuable !== 'function')
|
|
30
|
+
return unsupported('missing member creation API');
|
|
31
|
+
return host.startContinuable.call(runtime, spec);
|
|
32
|
+
}
|
|
33
|
+
/** Wrap every available creation API, restoring only this plugin's own methods. */
|
|
34
|
+
export function guardSubagentCreation(ctx, check) {
|
|
35
|
+
const runtime = ctx.subagents;
|
|
36
|
+
ctx.effect(() => {
|
|
37
|
+
const disposers = [];
|
|
38
|
+
for (const key of ['start', 'startContinuable', 'startActivation']) {
|
|
39
|
+
const original = runtime[key];
|
|
40
|
+
if (typeof original !== 'function')
|
|
41
|
+
continue;
|
|
42
|
+
const descriptor = Object.getOwnPropertyDescriptor(runtime, key);
|
|
43
|
+
const guarded = async (...args) => {
|
|
44
|
+
const request = key === 'start' ? args[1] : args[0].request;
|
|
45
|
+
check(request.parent);
|
|
46
|
+
return Reflect.apply(original, ctx.subagents, args);
|
|
47
|
+
};
|
|
48
|
+
Object.defineProperty(runtime, key, { value: guarded, writable: true, configurable: true });
|
|
49
|
+
disposers.push(() => {
|
|
50
|
+
if (Object.getOwnPropertyDescriptor(runtime, key)?.value !== guarded)
|
|
51
|
+
return;
|
|
52
|
+
if (descriptor === undefined)
|
|
53
|
+
Reflect.deleteProperty(runtime, key);
|
|
54
|
+
else
|
|
55
|
+
Object.defineProperty(runtime, key, descriptor);
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
return () => { for (const dispose of disposers)
|
|
59
|
+
dispose(); };
|
|
60
|
+
}, 'agent-teams: member delegation budget');
|
|
61
|
+
}
|
|
15
62
|
function boundary(runtime) {
|
|
16
63
|
return runtime;
|
|
17
64
|
}
|
|
18
65
|
function unsupported(detail) {
|
|
19
66
|
throw new Error(`agent-teams: unsupported Harness subagent contract (${detail}); use an explicitly tested Harness version and a coherent dependency installation`);
|
|
20
67
|
}
|
|
68
|
+
/** Package identity a released plugin-wrapper source names on older hosts. */
|
|
69
|
+
export const AGENT_TEAMS_PLUGIN_NAME = '@nanmicoder/dsh-agent-teams';
|
|
70
|
+
/**
|
|
71
|
+
* First stored format generation whose migration edges admit producer-owned
|
|
72
|
+
* source kinds. The released v2→v3 payload audit validates every message source
|
|
73
|
+
* against a closed list of released kinds, so a stored format older than this
|
|
74
|
+
* must carry a released source or the whole session becomes unreadable once a
|
|
75
|
+
* newer host opens it (#160).
|
|
76
|
+
*/
|
|
77
|
+
export const RELEASED_SOURCE_VOCABULARY_VERSION = 3;
|
|
78
|
+
/** Stored session format generation of the running host; 0 when it exposes none. */
|
|
79
|
+
export function hostSessionFormatVersion() {
|
|
80
|
+
const version = dshSession.SESSION_FORMAT_VERSION;
|
|
81
|
+
return typeof version === 'number' && Number.isSafeInteger(version) && version >= 0 ? version : 0;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Source for one AgentTeams-authored message on the running host.
|
|
85
|
+
*
|
|
86
|
+
* Current hosts mint producer-owned kinds: the Harness documents message
|
|
87
|
+
* sources as a merge-extensible vocabulary without a catch-all, so this plugin
|
|
88
|
+
* declares its own. Hosts that still store a pre-v3 generation are a different
|
|
89
|
+
* contract — their released v2→v3 migration rejects any source outside the
|
|
90
|
+
* released vocabulary, which is exactly how a plugin kind written on an older
|
|
91
|
+
* supported host poisons its session log for every later host. On those hosts
|
|
92
|
+
* the released plugin wrapper is used instead; the goal and profile already
|
|
93
|
+
* travel in the message text.
|
|
94
|
+
* @param modern - the producer-owned source to use where the host admits it.
|
|
95
|
+
* @returns a source accepted by the running host and by its migration chain.
|
|
96
|
+
*/
|
|
97
|
+
export function agentTeamsSource(modern) {
|
|
98
|
+
if (hostSessionFormatVersion() >= RELEASED_SOURCE_VOCABULARY_VERSION)
|
|
99
|
+
return modern;
|
|
100
|
+
// Released-format hosts persist plugin-authored messages as this wrapper; the
|
|
101
|
+
// current vocabulary retired it, so the cast stays confined to this branch
|
|
102
|
+
// and `modern` is the only shape a current host ever sees.
|
|
103
|
+
return { kind: 'plugin', plugin: AGENT_TEAMS_PLUGIN_NAME };
|
|
104
|
+
}
|
|
21
105
|
/**
|
|
22
106
|
* 0.1.6 moved startup admission to serial agent/created. Older created events
|
|
23
107
|
* have no source and precede session-start; ignore those until the old hook.
|
|
@@ -63,7 +147,7 @@ export function installContinuableMemberSetup(ctx, setup) {
|
|
|
63
147
|
const installed = new WeakSet();
|
|
64
148
|
const active = new Set();
|
|
65
149
|
ctx.effect(() => {
|
|
66
|
-
const
|
|
150
|
+
const attach = (agent, vetoable) => {
|
|
67
151
|
if (installed.has(agent))
|
|
68
152
|
return;
|
|
69
153
|
// Deliberately synchronous: awaiting here loses the first-request race.
|
|
@@ -101,18 +185,30 @@ export function installContinuableMemberSetup(ctx, setup) {
|
|
|
101
185
|
dispose();
|
|
102
186
|
throw error;
|
|
103
187
|
}
|
|
104
|
-
}
|
|
105
|
-
|
|
188
|
+
};
|
|
189
|
+
const stop = onAgentReady(ctx, attach);
|
|
190
|
+
const cleanup = () => {
|
|
106
191
|
stop();
|
|
107
192
|
for (const dispose of [...active])
|
|
108
193
|
dispose();
|
|
109
194
|
};
|
|
195
|
+
try {
|
|
196
|
+
// Reloads keep Agents alive but dispose the previous plugin's setup.
|
|
197
|
+
// Subscribe first, then hydrate the live registry; attach is idempotent.
|
|
198
|
+
for (const agent of ctx.agents.list())
|
|
199
|
+
attach(agent, false);
|
|
200
|
+
}
|
|
201
|
+
catch (error) {
|
|
202
|
+
cleanup();
|
|
203
|
+
throw error;
|
|
204
|
+
}
|
|
205
|
+
return cleanup;
|
|
110
206
|
}, 'agent-teams: member lifecycle compatibility');
|
|
111
207
|
}
|
|
112
208
|
/** Queue a distinct host-authored turn; never substitute model-message steer. */
|
|
113
209
|
export async function queueMemberPrompt(runtime, parent, childId, content, signal) {
|
|
114
210
|
const host = boundary(runtime);
|
|
115
|
-
const source = { kind: 'agent-teams' };
|
|
211
|
+
const source = agentTeamsSource({ kind: 'agent-teams' });
|
|
116
212
|
if (typeof host.followup === 'function') {
|
|
117
213
|
return host.followup.call(runtime, parent, childId, content, { source, signal });
|
|
118
214
|
}
|
|
@@ -127,7 +223,7 @@ export async function queueMemberPrompt(runtime, parent, childId, content, signa
|
|
|
127
223
|
/** Coordination joins the nearest step, including waking an idle/cold child. */
|
|
128
224
|
export async function steerMemberPrompt(runtime, parent, childId, content, signal, live) {
|
|
129
225
|
const host = boundary(runtime);
|
|
130
|
-
const source = { kind: 'agent-teams' };
|
|
226
|
+
const source = agentTeamsSource({ kind: 'agent-teams' });
|
|
131
227
|
const deliver = host[hostPromptDeliver];
|
|
132
228
|
if (typeof deliver === 'function')
|
|
133
229
|
return deliver.call(runtime, parent, childId, content, source, signal, 'steer');
|
|
@@ -247,15 +343,12 @@ export function restrictableToolNames(agent) {
|
|
|
247
343
|
* forwarding a name the host dropped aborts every member spawn (#163, #164).
|
|
248
344
|
* Depth enforcement itself does not depend on these names —
|
|
249
345
|
* installMemberDelegationGuard bounds descendant creation by parent chain.
|
|
346
|
+
*
|
|
347
|
+
* This is the legacy filter of a member without a configured tool policy; a
|
|
348
|
+
* selected policy resolves through `resolveMemberToolFilter` instead (#59).
|
|
250
349
|
*/
|
|
251
350
|
export function memberToolFilter(maxDepth, knownTools) {
|
|
252
|
-
|
|
253
|
-
return {
|
|
254
|
-
deny: [
|
|
255
|
-
...CAPTAIN_TOOL_NAMES,
|
|
256
|
-
...(knownTools === undefined ? depthDeny : depthDeny.filter(name => knownTools.has(name))),
|
|
257
|
-
],
|
|
258
|
-
};
|
|
351
|
+
return resolveMemberToolFilter(undefined, { maxDepth, knownTools });
|
|
259
352
|
}
|
|
260
353
|
/**
|
|
261
354
|
* Names a `tools.restrict()` rejection reported as unknown, or an empty list
|
|
@@ -270,33 +363,55 @@ function unknownToolNames(error) {
|
|
|
270
363
|
return [...message.matchAll(/"([^"]+)"/g)].map(match => match[1]);
|
|
271
364
|
}
|
|
272
365
|
/**
|
|
273
|
-
* Start one member, dropping
|
|
274
|
-
* retrying.
|
|
366
|
+
* Start one member, dropping only the built-in filter names the host reports
|
|
367
|
+
* as unknown and retrying.
|
|
275
368
|
*
|
|
276
369
|
* Resolving the filter against the registry view ({@link memberToolFilter})
|
|
277
370
|
* only covers hosts that expose one. A rejected filter still leaves the member
|
|
278
371
|
* without a session for the lifetime of the team, so the start must survive the
|
|
279
372
|
* cases that resolution cannot see — no registry view, a name that is known but
|
|
280
373
|
* not restrictable, a composition that mounted differently than expected. The
|
|
281
|
-
* host names the offenders in its rejection, so
|
|
282
|
-
*
|
|
283
|
-
*
|
|
374
|
+
* host names the offenders in its rejection, so the relaxable ones are removed
|
|
375
|
+
* and the start retried.
|
|
376
|
+
*
|
|
377
|
+
* Only `relaxableDeny` names may be dropped, and only from `deny`. The default
|
|
378
|
+
* is exactly the built-in captain-only and depth entries (#163/#164); a name an
|
|
379
|
+
* owner-declared tool policy contributed is never removed, because silently
|
|
380
|
+
* spawning a member with a different filter than the owner declared is contract
|
|
381
|
+
* drift, not recovery. Dropping a denied name the host does not register
|
|
382
|
+
* changes nothing; dropping an allowed name would loosen the policy, so `allow`
|
|
383
|
+
* is never relaxed and the four required coordination tools can never be
|
|
384
|
+
* removed.
|
|
284
385
|
* @param start - performs one start attempt for the given `toolFilter`.
|
|
285
386
|
* @param filter - the filter to attempt first.
|
|
387
|
+
* @param relaxableDeny - built-in deny names the retry may drop.
|
|
286
388
|
* @returns the started member.
|
|
287
389
|
*/
|
|
288
|
-
export async function startMemberWithLenientFilter(start, filter) {
|
|
289
|
-
|
|
390
|
+
export async function startMemberWithLenientFilter(start, filter, relaxableDeny = BUILTIN_DENY_TOOLS) {
|
|
391
|
+
const current = {
|
|
392
|
+
...filter.allow === undefined ? {} : { allow: [...filter.allow] },
|
|
393
|
+
deny: [...filter.deny],
|
|
394
|
+
};
|
|
290
395
|
for (;;) {
|
|
291
396
|
try {
|
|
292
|
-
return await start(
|
|
397
|
+
return await start(current);
|
|
293
398
|
}
|
|
294
399
|
catch (error) {
|
|
295
400
|
const unknown = unknownToolNames(error);
|
|
296
|
-
|
|
297
|
-
|
|
401
|
+
if (unknown.length === 0)
|
|
402
|
+
throw error;
|
|
403
|
+
const droppable = unknown.filter(name => relaxableDeny.includes(name) && current.deny.includes(name));
|
|
404
|
+
if (droppable.length === 0)
|
|
298
405
|
throw error;
|
|
299
|
-
deny =
|
|
406
|
+
current.deny = current.deny.filter(name => !droppable.includes(name));
|
|
300
407
|
}
|
|
301
408
|
}
|
|
302
409
|
}
|
|
410
|
+
/** Targeted recursive cancellation renamed with the unified activation manager. */
|
|
411
|
+
export async function drainMemberChildren(runtime, parent, ids) {
|
|
412
|
+
const host = runtime;
|
|
413
|
+
const drain = host.drainChildren ?? host.drainContinuableChildren;
|
|
414
|
+
if (typeof drain !== 'function')
|
|
415
|
+
return unsupported('missing targeted child drain');
|
|
416
|
+
await drain.call(runtime, parent, ids);
|
|
417
|
+
}
|
package/lib/index.js
CHANGED
|
@@ -27,7 +27,9 @@ import { join } from 'node:path';
|
|
|
27
27
|
import { fileURLToPath } from 'node:url';
|
|
28
28
|
import { collectArchivedTeamsActivity, collectTeamsActivity } from "./snapshot.js";
|
|
29
29
|
import { findTeamByCaptain } from "./state.js";
|
|
30
|
+
import { leadIdentityOfAsync } from "./lineage.js";
|
|
30
31
|
import { formatProfilesForPrompt } from "./profiles.js";
|
|
32
|
+
import { parseToolPolicies } from "./tool-policies.js";
|
|
31
33
|
import { installTeamCapabilities } from "./capabilities.js";
|
|
32
34
|
import { TEAM_TOOL_NAMES } from "./tool-names.js";
|
|
33
35
|
import { authenticatedWebRoutes, readJsonRequest, RequestBodyError } from "./web-routes.js";
|
|
@@ -44,10 +46,20 @@ const fallbackRouteConfig = z.union([
|
|
|
44
46
|
z.object({ provider: z.string().required(), model: z.string().required() }),
|
|
45
47
|
z.const(undefined),
|
|
46
48
|
]);
|
|
49
|
+
// Same Schemastery defaulting trap for a tool policy: an `allow`-only policy
|
|
50
|
+
// would materialize `deny: []` and fail a `.min(1)` on an absent list. Model
|
|
51
|
+
// each accepted combination explicitly, so a policy that declares only one
|
|
52
|
+
// side stays valid while an empty side is still rejected.
|
|
53
|
+
const toolPolicyConfig = z.union([
|
|
54
|
+
z.object({ allow: z.array(z.string()).min(1).required(), deny: z.const(undefined) }),
|
|
55
|
+
z.object({ allow: z.const(undefined), deny: z.array(z.string()).min(1).required() }),
|
|
56
|
+
z.object({ allow: z.array(z.string()).min(1).required(), deny: z.array(z.string()).min(1).required() }),
|
|
57
|
+
]);
|
|
47
58
|
export const Config = z.object({
|
|
48
59
|
stateDir: z.string().default('.agent-teams'),
|
|
49
60
|
memberProvider: z.string().default('spawn'),
|
|
50
61
|
memberModel: z.string(),
|
|
62
|
+
memberLlmProvider: z.string(),
|
|
51
63
|
executionPrompt: z.string(),
|
|
52
64
|
fallback: fallbackRouteConfig,
|
|
53
65
|
profiles: z.dict(z.object({
|
|
@@ -63,6 +75,8 @@ export const Config = z.object({
|
|
|
63
75
|
reasoning_effort: z.string(),
|
|
64
76
|
executionPrompt: z.string(),
|
|
65
77
|
fallback: fallbackRouteConfig,
|
|
78
|
+
// Owner-declared tool-policy id this profile member selects (#59).
|
|
79
|
+
tool_policy: z.string(),
|
|
66
80
|
})).min(1).required(),
|
|
67
81
|
taskPlanning: z.union([z.const('captain'), z.const('seed')]),
|
|
68
82
|
reviewPolicy: z.object({
|
|
@@ -80,6 +94,7 @@ export const Config = z.object({
|
|
|
80
94
|
dependencies: z.array(z.string()),
|
|
81
95
|
})),
|
|
82
96
|
})).default({}),
|
|
97
|
+
toolPolicies: z.dict(toolPolicyConfig).default({}),
|
|
83
98
|
memberMaxDepth: z.natural().default(0),
|
|
84
99
|
maxMembers: z.natural().min(1).default(8),
|
|
85
100
|
promptSectionOrder: z.natural().default(117),
|
|
@@ -89,7 +104,7 @@ export const Config = z.object({
|
|
|
89
104
|
export function usageSectionText(toolNames, profilesText = '') {
|
|
90
105
|
return `AgentTeams captain protocol:
|
|
91
106
|
1. Inspect current team state when needed, using agent_teams_status. Continue existing work without duplicating its roster/tasks. Create only when no current team exists, with the user's goal as description and approval="required"; automatic approval requires an explicit request to run immediately. Staged plans never spawn or schedule work.
|
|
92
|
-
2. Add each needed role once; members inherit your model route unless another is requested/needed. A requested profile goes to create({profile}); it supplies its roster. Seed profiles also supply tasks; captain-planning profiles require your DAG. Do not duplicate either.
|
|
107
|
+
2. Add each needed role once; members inherit your model route unless another is requested/needed. A requested profile goes to create({profile}); it supplies its roster. Seed profiles also supply tasks; captain-planning profiles require your DAG. Do not duplicate either. A member may also select one owner-declared tool policy by id (tool_policy on add_member, a plan member, or a profile member): it is the only way to change which global tools that member sees and may invoke, and you can never pass raw allow/deny lists.
|
|
93
108
|
3. Build the complete smallest useful DAG while staged. For an ordinary research/audit plan, pass the roster and dependency graph together in create({plan:{members,tasks}}) to avoid repeated setup rounds. Every task needs a subject; pass kind and assignee explicitly when the plan specifies them. Titles, descriptions and member roles do not set these fields. Dependencies represent prerequisites. Give every required contributor a task or explicit message. Present the plan and end your turn for review; never approve in that planning turn. Approve only after a later explicit user approval or the Web action.
|
|
94
109
|
4. Respect Web approve/return/discard control messages. On return, ask what to change before editing; after the answer, use one atomic agent_teams_edit_plan batch (edit downstream references before removals), summarize and await review again. Never inspect or edit .agent-teams state files or plugin source code to revise plans. Discard does not authorize a replacement.
|
|
95
110
|
5. The scheduler dispatches ready tasks after approval. Delegate; do not duplicate slow work or send messages merely to start a stage. Handle reports/user work, then yield when waiting is all that remains: reports wake you automatically. Use status after a delivery or user request, never busy-poll or wait for unassigned members.
|
|
@@ -105,11 +120,15 @@ export function apply(ctx, config) {
|
|
|
105
120
|
stateDir: config.stateDir ?? '.agent-teams',
|
|
106
121
|
memberProvider: config.memberProvider ?? 'spawn',
|
|
107
122
|
memberModel: config.memberModel,
|
|
123
|
+
memberLlmProvider: config.memberLlmProvider,
|
|
108
124
|
executionPrompt: config.executionPrompt,
|
|
109
125
|
fallback: config.fallback,
|
|
110
126
|
memberMaxDepth: config.memberMaxDepth ?? 0,
|
|
111
127
|
maxMembers: config.maxMembers ?? 8,
|
|
112
128
|
profiles: config.profiles ?? {},
|
|
129
|
+
// Owner configuration is validated once, at mount: an empty or malformed
|
|
130
|
+
// policy must never reach a member creation path.
|
|
131
|
+
toolPolicies: parseToolPolicies(config.toolPolicies),
|
|
113
132
|
};
|
|
114
133
|
// Provider registration is a sibling plugin's effect (`subagent-spawn` /
|
|
115
134
|
// `subagent-fork` rows), which can land after this mount under the Loader's
|
|
@@ -166,6 +185,9 @@ export function apply(ctx, config) {
|
|
|
166
185
|
const url = new URL(req.url ?? '/', 'http://x');
|
|
167
186
|
const roots = workspaceRegistry.list().map((workspace) => ({
|
|
168
187
|
workspace: workspace.title,
|
|
188
|
+
// The panel audits the same current bytes as agent_teams_status, so it
|
|
189
|
+
// cannot advertise a passing gate over a changed revision (#161).
|
|
190
|
+
workspacePath: workspace.path,
|
|
169
191
|
stateRoot: join(workspace.path, resolved.stateDir),
|
|
170
192
|
}));
|
|
171
193
|
// ?archived=1 serves teams moved to archive/ (post-delete review).
|
|
@@ -213,7 +235,7 @@ export function apply(ctx, config) {
|
|
|
213
235
|
}
|
|
214
236
|
const workspace = captain.session.header.cwd ?? process.cwd();
|
|
215
237
|
const stateRoot = join(workspace, resolved.stateDir);
|
|
216
|
-
const team = await findTeamByCaptain(stateRoot, captain.id);
|
|
238
|
+
const team = await findTeamByCaptain(stateRoot, captain.id, await leadIdentityOfAsync(ctx, captain.session));
|
|
217
239
|
if (team === undefined || team.id !== teamId) {
|
|
218
240
|
res.writeHead(404, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
|
|
219
241
|
res.end(JSON.stringify({ error: 'team not found for this captain' }));
|
|
@@ -270,7 +292,7 @@ export function apply(ctx, config) {
|
|
|
270
292
|
}
|
|
271
293
|
const workspace = captain.session.header.cwd ?? process.cwd();
|
|
272
294
|
const stateRoot = join(workspace, resolved.stateDir);
|
|
273
|
-
const team = await findTeamByCaptain(stateRoot, captain.id);
|
|
295
|
+
const team = await findTeamByCaptain(stateRoot, captain.id, await leadIdentityOfAsync(ctx, captain.session));
|
|
274
296
|
if (team === undefined || team.id !== teamId) {
|
|
275
297
|
res.writeHead(404, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
|
|
276
298
|
res.end(JSON.stringify({ error: 'team not found for this captain' }));
|
|
@@ -330,6 +352,7 @@ export function apply(ctx, config) {
|
|
|
330
352
|
...typeof payload['executionPrompt'] === 'string' || payload['executionPrompt'] === null
|
|
331
353
|
? { executionPrompt: payload['executionPrompt'] }
|
|
332
354
|
: {},
|
|
355
|
+
...typeof payload['tool_policy'] === 'string' ? { toolPolicy: payload['tool_policy'] } : {},
|
|
333
356
|
};
|
|
334
357
|
}
|
|
335
358
|
else if (action === 'update_task') {
|
package/lib/lineage.js
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/** Maximum fork-chain depth resolved for one identity (cycle- and length-bounded). */
|
|
2
|
+
export const LINEAGE_MAX_DEPTH = 32;
|
|
3
|
+
/**
|
|
4
|
+
* Official optional service access. `ctx.get` is the reflection accessor for a
|
|
5
|
+
* service a plugin does not declare in `inject`; a missing service yields
|
|
6
|
+
* `undefined` instead of a contract error, and a getter that still refuses is
|
|
7
|
+
* contained here.
|
|
8
|
+
*/
|
|
9
|
+
function optionalService(ctx, name) {
|
|
10
|
+
const get = ctx.get;
|
|
11
|
+
if (typeof get !== 'function')
|
|
12
|
+
return undefined;
|
|
13
|
+
try {
|
|
14
|
+
return get.call(ctx, name);
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return undefined;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
/** Ancestry this process already resolved for one session. */
|
|
21
|
+
const ancestryCache = new Map();
|
|
22
|
+
/** The live session for one id: resident agent first, then the live session store. */
|
|
23
|
+
export function liveSession(ctx, id) {
|
|
24
|
+
const branded = id;
|
|
25
|
+
const agent = ctx.agents.get(branded);
|
|
26
|
+
if (agent !== undefined)
|
|
27
|
+
return agent.session;
|
|
28
|
+
return optionalService(ctx, 'sessions')?.get?.(branded);
|
|
29
|
+
}
|
|
30
|
+
/** Whether one session is a top-level conversation rather than a subagent child. */
|
|
31
|
+
export function isTopLevelSession(session) {
|
|
32
|
+
return session.header.origin !== 'subagent' && (session.header.delegationDepth ?? 0) === 0;
|
|
33
|
+
}
|
|
34
|
+
/** Durable parent of one session id: the live header, else the stored header. */
|
|
35
|
+
async function durableParent(ctx, id) {
|
|
36
|
+
const live = liveSession(ctx, id);
|
|
37
|
+
if (live !== undefined)
|
|
38
|
+
return live.header.parentSession;
|
|
39
|
+
const persistence = optionalService(ctx, 'sessionPersistence');
|
|
40
|
+
try {
|
|
41
|
+
const snapshot = await persistence?.stat?.(id);
|
|
42
|
+
const parent = snapshot?.header?.parentSession;
|
|
43
|
+
return parent;
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
// Unreadable stored metadata ends this chain; authorization then fails
|
|
47
|
+
// closed on the identity the caller itself provides.
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Durable ancestry of one session, nearest first. The calling session's own
|
|
53
|
+
* header supplies the first hop; later hops come from resident sessions or
|
|
54
|
+
* stored headers, so a fork resolves through sessions that are not loaded.
|
|
55
|
+
* @param ctx - the plugin context (injects `agents`).
|
|
56
|
+
* @param session - the calling session.
|
|
57
|
+
* @returns ancestor session ids, bounded by {@link LINEAGE_MAX_DEPTH}.
|
|
58
|
+
*/
|
|
59
|
+
export async function resolveAncestry(ctx, session) {
|
|
60
|
+
const ancestry = [];
|
|
61
|
+
const seen = new Set([session.id]);
|
|
62
|
+
let parent = session.header.parentSession;
|
|
63
|
+
while (parent !== undefined && ancestry.length < LINEAGE_MAX_DEPTH && !seen.has(parent)) {
|
|
64
|
+
seen.add(parent);
|
|
65
|
+
ancestry.push(parent);
|
|
66
|
+
parent = await durableParent(ctx, parent);
|
|
67
|
+
}
|
|
68
|
+
ancestryCache.set(session.id, ancestry);
|
|
69
|
+
return ancestry;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Ancestry resolvable without awaiting: the calling session's durable first
|
|
73
|
+
* hop, then resident sessions, then whatever the async resolver cached.
|
|
74
|
+
*/
|
|
75
|
+
export function sessionAncestry(ctx, session) {
|
|
76
|
+
const cached = ancestryCache.get(session.id);
|
|
77
|
+
if (cached !== undefined)
|
|
78
|
+
return cached;
|
|
79
|
+
const ancestry = [];
|
|
80
|
+
const seen = new Set([session.id]);
|
|
81
|
+
let parent = session.header.parentSession;
|
|
82
|
+
while (parent !== undefined && ancestry.length < LINEAGE_MAX_DEPTH && !seen.has(parent)) {
|
|
83
|
+
seen.add(parent);
|
|
84
|
+
ancestry.push(parent);
|
|
85
|
+
parent = liveSession(ctx, parent)?.header.parentSession;
|
|
86
|
+
}
|
|
87
|
+
return ancestry;
|
|
88
|
+
}
|
|
89
|
+
/** Resolve one session's lead identity synchronously (prompt-assembly path). */
|
|
90
|
+
export function leadIdentityOf(ctx, session) {
|
|
91
|
+
return {
|
|
92
|
+
sessionId: session.id,
|
|
93
|
+
ancestry: sessionAncestry(ctx, session),
|
|
94
|
+
inheritsLead: isTopLevelSession(session),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
/** Resolve one session's lead identity durably (authorization paths). */
|
|
98
|
+
export async function leadIdentityOfAsync(ctx, session) {
|
|
99
|
+
return {
|
|
100
|
+
sessionId: session.id,
|
|
101
|
+
ancestry: await resolveAncestry(ctx, session),
|
|
102
|
+
inheritsLead: isTopLevelSession(session),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
/** Whether one identity leads (or inherits the lead of) a team's recorded captain. */
|
|
106
|
+
export function teamLeadMatch(team, lead) {
|
|
107
|
+
const members = team.members ?? [];
|
|
108
|
+
// A listed member is never a lead, retired or not: a member session is a
|
|
109
|
+
// subagent child of its captain, so its parent chain contains the captain id
|
|
110
|
+
// too, and membership revocation must never hand that session lead authority.
|
|
111
|
+
// Member participation itself stays governed by the removed filter in the
|
|
112
|
+
// team lookups.
|
|
113
|
+
if (members.some(member => member.id === lead.sessionId))
|
|
114
|
+
return false;
|
|
115
|
+
if (team.captainSessionId === lead.sessionId)
|
|
116
|
+
return true;
|
|
117
|
+
if (!lead.inheritsLead)
|
|
118
|
+
return false;
|
|
119
|
+
// Descending from a member (a fork of one) is not lead identity either.
|
|
120
|
+
if (members.some(member => lead.ancestry.includes(member.id)))
|
|
121
|
+
return false;
|
|
122
|
+
return lead.ancestry.includes(team.captainSessionId);
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Whether one live session id leads a team: the recorded captain itself, or a
|
|
126
|
+
* top-level fork descendant of it. Members never match through their parent
|
|
127
|
+
* chain, and a member's fork does not match either.
|
|
128
|
+
*/
|
|
129
|
+
export function sessionLeadsTeam(ctx, team, sessionId) {
|
|
130
|
+
if (team.members?.some(member => member.id === sessionId) === true)
|
|
131
|
+
return false;
|
|
132
|
+
if (team.captainSessionId === sessionId)
|
|
133
|
+
return true;
|
|
134
|
+
const session = liveSession(ctx, sessionId);
|
|
135
|
+
return session !== undefined && teamLeadMatch(team, leadIdentityOf(ctx, session));
|
|
136
|
+
}
|
|
137
|
+
/** Durable {@link sessionLeadsTeam}: resolves the caller's chain through stored headers. */
|
|
138
|
+
export async function sessionLeadsTeamAsync(ctx, team, sessionId) {
|
|
139
|
+
if (team.members?.some(member => member.id === sessionId) === true)
|
|
140
|
+
return false;
|
|
141
|
+
if (team.captainSessionId === sessionId)
|
|
142
|
+
return true;
|
|
143
|
+
const session = liveSession(ctx, sessionId);
|
|
144
|
+
return session !== undefined && teamLeadMatch(team, await leadIdentityOfAsync(ctx, session));
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Nearest live lead session of a team: the recorded captain while it is
|
|
148
|
+
* resident, otherwise the closest live fork descendant of it. Captain-directed
|
|
149
|
+
* reports use this so a human working in a fork still receives them, while the
|
|
150
|
+
* durable mail record stays addressed to the recorded captain.
|
|
151
|
+
* @param ctx - the plugin context (injects `agents`).
|
|
152
|
+
* @param team - current team state.
|
|
153
|
+
* @returns the live lead agent, or undefined when no lead session is resident.
|
|
154
|
+
*/
|
|
155
|
+
export function nearestLiveLead(ctx, team, live) {
|
|
156
|
+
// The recorded captain is the delivery parent whenever it is resident; a
|
|
157
|
+
// registry without `list` (or with rows the lineage cannot read) only means
|
|
158
|
+
// the fork fallback is unavailable, never that this lookup fails.
|
|
159
|
+
const exact = ctx.agents.get(team.captainSessionId);
|
|
160
|
+
if (exact !== undefined)
|
|
161
|
+
return exact;
|
|
162
|
+
let candidates;
|
|
163
|
+
try {
|
|
164
|
+
candidates = live ?? ctx.agents.list();
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
return undefined;
|
|
168
|
+
}
|
|
169
|
+
let best;
|
|
170
|
+
let bestDistance = Number.POSITIVE_INFINITY;
|
|
171
|
+
for (const agent of candidates) {
|
|
172
|
+
try {
|
|
173
|
+
const identity = leadIdentityOf(ctx, agent.session);
|
|
174
|
+
if (!teamLeadMatch(team, identity))
|
|
175
|
+
continue;
|
|
176
|
+
const distance = identity.ancestry.indexOf(team.captainSessionId);
|
|
177
|
+
if (distance < 0 || distance >= bestDistance)
|
|
178
|
+
continue;
|
|
179
|
+
best = agent;
|
|
180
|
+
bestDistance = distance;
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return best;
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Diagnostic note for a caller that matched no team but is a fork, so an
|
|
190
|
+
* operator can distinguish "no team exists" from "a team exists that this
|
|
191
|
+
* session does not lead".
|
|
192
|
+
*/
|
|
193
|
+
export function forkLineageNote(session) {
|
|
194
|
+
const parent = session.header.parentSession;
|
|
195
|
+
if (parent === undefined)
|
|
196
|
+
return '';
|
|
197
|
+
return ` (this session is a fork of ${parent}; no active team in this workspace is led by that session or by a lead session in its lineage)`;
|
|
198
|
+
}
|