@aragon-agent/cli 0.5.2 → 0.5.3

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.
Files changed (83) hide show
  1. package/CHANGELOG.md +90 -0
  2. package/README.md +128 -1
  3. package/dist/agent/controller.d.ts +51 -1
  4. package/dist/agent/controller.js +177 -2
  5. package/dist/agent/headless.d.ts +12 -0
  6. package/dist/agent/headless.js +41 -1
  7. package/dist/agent/reducer.d.ts +48 -0
  8. package/dist/agent/reducer.js +91 -2
  9. package/dist/agent/system-prompt.d.ts +17 -0
  10. package/dist/agent/system-prompt.js +4 -0
  11. package/dist/cli.js +66 -2
  12. package/dist/commands/builtins.js +82 -0
  13. package/dist/config/app-paths.d.ts +17 -0
  14. package/dist/config/app-paths.js +21 -0
  15. package/dist/config/cli-commands.d.ts +26 -2
  16. package/dist/config/cli-commands.js +63 -4
  17. package/dist/config/env.d.ts +2 -2
  18. package/dist/config/env.js +28 -2
  19. package/dist/config/history-commands.d.ts +20 -0
  20. package/dist/config/history-commands.js +71 -0
  21. package/dist/config/load.d.ts +13 -0
  22. package/dist/config/load.js +65 -5
  23. package/dist/config/migrate-state-out-of-config.d.ts +32 -0
  24. package/dist/config/migrate-state-out-of-config.js +85 -0
  25. package/dist/config/prompt-history.d.ts +86 -0
  26. package/dist/config/prompt-history.js +332 -0
  27. package/dist/config/schema.d.ts +112 -16
  28. package/dist/config/schema.js +89 -6
  29. package/dist/config/store.js +13 -4
  30. package/dist/config/ui-state.d.ts +52 -0
  31. package/dist/config/ui-state.js +149 -0
  32. package/dist/logging/install.d.ts +85 -0
  33. package/dist/logging/install.js +94 -0
  34. package/dist/logging/logger.d.ts +1 -1
  35. package/dist/session/persist.d.ts +16 -0
  36. package/dist/session/persist.js +19 -1
  37. package/dist/team/activity.d.ts +67 -0
  38. package/dist/team/activity.js +130 -0
  39. package/dist/team/bus.d.ts +190 -0
  40. package/dist/team/bus.js +366 -0
  41. package/dist/team/comm-tools.d.ts +61 -0
  42. package/dist/team/comm-tools.js +221 -0
  43. package/dist/team/human-queue.d.ts +55 -0
  44. package/dist/team/human-queue.js +72 -0
  45. package/dist/team/limits.d.ts +123 -0
  46. package/dist/team/limits.js +128 -0
  47. package/dist/team/normalize.d.ts +39 -0
  48. package/dist/team/normalize.js +106 -0
  49. package/dist/team/panel-rows.d.ts +48 -0
  50. package/dist/team/panel-rows.js +75 -0
  51. package/dist/team/prompt.d.ts +49 -0
  52. package/dist/team/prompt.js +87 -0
  53. package/dist/team/report.d.ts +58 -0
  54. package/dist/team/report.js +205 -0
  55. package/dist/team/retry.d.ts +48 -0
  56. package/dist/team/retry.js +56 -0
  57. package/dist/team/runtime.d.ts +122 -0
  58. package/dist/team/runtime.js +406 -0
  59. package/dist/team/subagent.d.ts +90 -0
  60. package/dist/team/subagent.js +294 -0
  61. package/dist/team/task-tool.d.ts +47 -0
  62. package/dist/team/task-tool.js +118 -0
  63. package/dist/team/types.d.ts +172 -0
  64. package/dist/team/types.js +13 -0
  65. package/dist/tools/index.d.ts +48 -2
  66. package/dist/tools/index.js +26 -1
  67. package/dist/ui/App.js +97 -13
  68. package/dist/ui/StatusBar.d.ts +21 -0
  69. package/dist/ui/StatusBar.js +5 -2
  70. package/dist/ui/TeamPanel.d.ts +57 -0
  71. package/dist/ui/TeamPanel.js +134 -0
  72. package/dist/ui/Transcript.js +10 -0
  73. package/dist/ui/entries/TeamCard.d.ts +31 -0
  74. package/dist/ui/entries/TeamCard.js +39 -0
  75. package/dist/ui/glyphs.d.ts +10 -0
  76. package/dist/ui/glyphs.js +6 -0
  77. package/dist/ui/layout/AppShell.d.ts +11 -1
  78. package/dist/ui/layout/AppShell.js +3 -3
  79. package/dist/ui/overlays/HelpOverlay.js +12 -0
  80. package/dist/ui/transcript-text.js +20 -0
  81. package/dist/ui/use-startup-notices.d.ts +1 -1
  82. package/dist/ui/use-startup-notices.js +2 -1
  83. package/package.json +2 -2
@@ -0,0 +1,90 @@
1
+ /**
2
+ * `createSubagent` — one child `Agent`, its tool set, and the instrumentation
3
+ * that turns core events into a `SubagentRun` (team-subagents §3.4).
4
+ *
5
+ * ASCII ONLY: `src/team/**` is inside the glyph scanner's scope.
6
+ *
7
+ * A CHILD IS BUILT FROM THE SAME FACTORY THE LEAD USES. That is the point: the
8
+ * `--confirm` gate, the skills ceiling and the plan-mode gate are re-applied one
9
+ * level down by the same wrappers, so delegation cannot be a way around any of
10
+ * them (§3.11 / R-6). What a child does NOT get is spelled out below, and each
11
+ * omission has a reason that is a correctness boundary rather than caution.
12
+ */
13
+ import { type AgentConfig, type AgentEvent, type AgentTool, type ProviderRegistry, type SkillRegistry, type TokenUsage, type ToolPolicyDecision, type ToolPolicyVerdict } from '@aragon-agent/core';
14
+ import type { AgentMode } from '../agent/agent-mode.js';
15
+ import type { CliConfig } from '../config/schema.js';
16
+ import { TeamBus } from './bus.js';
17
+ import type { TeamHumanQueue, WatchdogPausable } from './human-queue.js';
18
+ import type { SubagentRun, SubagentSpec } from './types.js';
19
+ /**
20
+ * The slice of `Agent` a child is used through.
21
+ *
22
+ * Structural rather than the concrete class so `TeamRuntime` can be handed a
23
+ * stub factory and the whole scheduler can be tested without a network — the
24
+ * same seam `HeadlessController` already uses.
25
+ */
26
+ export interface SubagentAgentLike extends WatchdogPausable {
27
+ prompt(text: string): Promise<void>;
28
+ abort(): void;
29
+ subscribe(listener: (event: AgentEvent) => void): () => void;
30
+ }
31
+ export type SubagentAgentFactory = (config: AgentConfig) => SubagentAgentLike;
32
+ /** The default factory: a real `Agent` from `@aragon-agent/core`. */
33
+ export declare const defaultSubagentFactory: SubagentAgentFactory;
34
+ /** Everything a child needs that is the same for every child in a dispatch. */
35
+ export interface SubagentDeps {
36
+ bus: TeamBus;
37
+ config: CliConfig;
38
+ providerRegistry: ProviderRegistry;
39
+ getCwd: () => string;
40
+ /** The lead's live session mode. Read at CALL time, never cached. */
41
+ getMode: () => AgentMode;
42
+ getApiKey: (providerId: string) => string | undefined;
43
+ /**
44
+ * The lead's `SkillRegistry`, used for `skill_find` ONLY.
45
+ *
46
+ * `undefined` when skills are off, in which case the child gets no skill tool
47
+ * at all — the same shape `--no-skills` produces for the lead.
48
+ */
49
+ skillRegistry?: SkillRegistry;
50
+ /** The lead's always-on skill bodies, spliced into every child's prompt. */
51
+ alwaysBlock?: string;
52
+ toolPolicy?: () => ToolPolicyDecision;
53
+ /** Already label-decorating; see `TeamRuntime` (§3.11 / P1-3). */
54
+ onChildToolPolicyEvent?: (e: {
55
+ label: string;
56
+ tool: string;
57
+ verdict: ToolPolicyVerdict;
58
+ sourceNames: string[];
59
+ }) => void;
60
+ confirmTools: boolean;
61
+ /** Present only when `--confirm` is on AND a human channel exists. */
62
+ humanQueue?: TeamHumanQueue;
63
+ agentFactory: SubagentAgentFactory;
64
+ }
65
+ export interface SubagentHooks {
66
+ /** Coalesced at the source (§5.2); phase transitions always pass. */
67
+ onUpdate: (run: SubagentRun) => void;
68
+ onUsage: (label: string, usage: TokenUsage) => void;
69
+ }
70
+ export interface SubagentHandle {
71
+ spec: SubagentSpec;
72
+ run: SubagentRun;
73
+ agent: SubagentAgentLike;
74
+ tools: AgentTool[];
75
+ unsubscribe: () => void;
76
+ }
77
+ /**
78
+ * Build a child's tool array.
79
+ *
80
+ * Exported so `team-tool-gate.test.ts` can assert what a child may and may not
81
+ * call without constructing an `Agent` or reaching a network — the tool set IS
82
+ * the security boundary here, so it has to be directly checkable.
83
+ */
84
+ export declare function buildSubagentTools(spec: SubagentSpec, deps: SubagentDeps, getAgent: () => WatchdogPausable | null): AgentTool[];
85
+ /**
86
+ * Construct a child and wire its instrumentation.
87
+ *
88
+ * The returned agent has NOT been prompted; `TeamRuntime` owns scheduling.
89
+ */
90
+ export declare function createSubagent(spec: SubagentSpec, deps: SubagentDeps, hooks: SubagentHooks): SubagentHandle;
@@ -0,0 +1,294 @@
1
+ /**
2
+ * `createSubagent` — one child `Agent`, its tool set, and the instrumentation
3
+ * that turns core events into a `SubagentRun` (team-subagents §3.4).
4
+ *
5
+ * ASCII ONLY: `src/team/**` is inside the glyph scanner's scope.
6
+ *
7
+ * A CHILD IS BUILT FROM THE SAME FACTORY THE LEAD USES. That is the point: the
8
+ * `--confirm` gate, the skills ceiling and the plan-mode gate are re-applied one
9
+ * level down by the same wrappers, so delegation cannot be a way around any of
10
+ * them (§3.11 / R-6). What a child does NOT get is spelled out below, and each
11
+ * omission has a reason that is a correctness boundary rather than caution.
12
+ */
13
+ import { Agent, createSkillFindTool, } from '@aragon-agent/core';
14
+ import { formatStreamError } from '../agent/reducer.js';
15
+ import { buildSystemPrompt } from '../agent/system-prompt.js';
16
+ import { createBuiltinTools } from '../tools/index.js';
17
+ import { pickActivityArgs, sanitizeActivity } from './activity.js';
18
+ import { TEAM_LIMITS, TEAM_SUBAGENT_TOOL_NAMES } from './limits.js';
19
+ import { isRetryableStreamError } from './retry.js';
20
+ import { makeTeamSend, makeTeamWait, withMailboxTail } from './comm-tools.js';
21
+ import { buildSubagentBlock } from './prompt.js';
22
+ /** The default factory: a real `Agent` from `@aragon-agent/core`. */
23
+ export const defaultSubagentFactory = (config) => new Agent(config);
24
+ /** The final assistant text of a turn, or `''` when the turn was tool calls only. */
25
+ function assistantText(message) {
26
+ if (!message || !Array.isArray(message.content))
27
+ return '';
28
+ const parts = [];
29
+ for (const block of message.content) {
30
+ if (block.type === 'text')
31
+ parts.push(block.text);
32
+ }
33
+ return parts.join('').trim();
34
+ }
35
+ /**
36
+ * Build a child's tool array.
37
+ *
38
+ * Exported so `team-tool-gate.test.ts` can assert what a child may and may not
39
+ * call without constructing an `Agent` or reaching a network — the tool set IS
40
+ * the security boundary here, so it has to be directly checkable.
41
+ */
42
+ export function buildSubagentTools(spec, deps, getAgent) {
43
+ const commTools = [
44
+ makeTeamSend(deps.bus, spec.label),
45
+ makeTeamWait(deps.bus, spec.label, {
46
+ getAgent,
47
+ maxWaitMs: deps.config.team.subagentTimeoutMs,
48
+ }),
49
+ ];
50
+ const tools = createBuiltinTools({
51
+ getCwd: deps.getCwd,
52
+ confirmTools: deps.confirmTools,
53
+ // Queued through the ONE human slot, with this child's watchdog paused for
54
+ // the whole queue wait (§3.11 / I-4 / P0-4).
55
+ ...(deps.confirmTools && deps.humanQueue
56
+ ? {
57
+ // `req.signal` is the CHILD's `ctx.signal`, carried through
58
+ // `ConfirmRequest` because `withConfirmation` is the only frame that
59
+ // holds it. Without it an abort mid-dispatch would still pop a dialog
60
+ // for every request left in the FIFO, one after another, at a user who
61
+ // has just pressed Esc — AC-16's "leaves nothing pending" would hold
62
+ // for the queue's unit test and not for the session.
63
+ confirm: (req) => deps.humanQueue.request(getAgent(), spec.label, req, req.signal),
64
+ }
65
+ : {}),
66
+ // `skill_find` ONLY, and this is a CORRECTNESS BOUNDARY rather than
67
+ // conservatism (D-16 / P0-3 / R-16). `skill_find` is a pure registry lookup.
68
+ // `skill` is not: `createSkillTool` calls `enterFrame()` on the registry it
69
+ // is handed, and the only registry in the process is the LEAD's. A child
70
+ // loading a skill would push a frame onto the lead's turn-scoped ceiling —
71
+ // narrowing what the LEAD may do once the report returns — and fire
72
+ // `onChange` -> `refreshSkills()` -> `rebuildSystemPrompt()`, rewriting the
73
+ // lead's system prompt from inside the tool call the lead is blocked in,
74
+ // from up to `maxConcurrent` places at once. Nothing in the child's report
75
+ // would show any of it.
76
+ //
77
+ // The capability is not simply dropped: the lead's always-on skill bodies
78
+ // are spliced into every child's system prompt below, so global skill
79
+ // knowledge still reaches children. A child that needs a specific skill body
80
+ // says so in its summary and the lead loads it.
81
+ //
82
+ // `skill_install` / `skill_create` are excluded for the original reason:
83
+ // they mutate the user's skill root and route to an approval gate no child
84
+ // can reach.
85
+ skillTools: deps.skillRegistry
86
+ ? [createSkillFindTool({ registry: deps.skillRegistry })]
87
+ : [],
88
+ // No `ask_user`, no `submit_plan`: there is no human attached to a child, and
89
+ // registering tools it could never use burns a turn — the same argument that
90
+ // keeps them out of headless mode.
91
+ planTools: [],
92
+ teamTools: commTools,
93
+ // `spec.readOnly` forces the plan gate on for this child even in a BUILD
94
+ // session. TIGHTENING ONLY (D-13): there is no field anywhere that gives a
95
+ // child more permission than the session it was spawned from. Read LIVE, so
96
+ // a mid-dispatch Shift+Tab to PLAN tightens every running child at its next
97
+ // tool call.
98
+ agentMode: () => (spec.readOnly ? 'plan' : deps.getMode()),
99
+ ...(deps.toolPolicy
100
+ ? {
101
+ toolPolicy: deps.toolPolicy,
102
+ // Decorated with the child's label, NOT the lead's raw handler (P1-3).
103
+ // `evaluateToolCall` returns a notice naming a tool and a skill but no
104
+ // agent, so three children hitting one refusal would surface three
105
+ // unattributable notices; and the lead's deny-escalation counter is
106
+ // turn-scoped, so passing it through would advance it three times for
107
+ // what is one refusal repeated.
108
+ onToolPolicyEvent: (e) => deps.onChildToolPolicyEvent?.({ ...e, label: spec.label }),
109
+ // The two comm tools are not in the LEAD's registered tool list, so
110
+ // the ceiling would refuse them and dead-end the channel.
111
+ policyExempt: TEAM_SUBAGENT_TOOL_NAMES,
112
+ }
113
+ : {}),
114
+ });
115
+ // Mail rides on the NEXT tool result of whatever the child does next (D-4), so
116
+ // every tool it can call has to carry the tail — the comm tools included, or a
117
+ // child that only ever messages would never receive.
118
+ return tools.map((t) => withMailboxTail(t, deps.bus, spec.label));
119
+ }
120
+ /**
121
+ * Construct a child and wire its instrumentation.
122
+ *
123
+ * The returned agent has NOT been prompted; `TeamRuntime` owns scheduling.
124
+ */
125
+ export function createSubagent(spec, deps, hooks) {
126
+ const run = {
127
+ label: spec.label,
128
+ description: spec.description,
129
+ phase: 'queued',
130
+ turns: 0,
131
+ toolCalls: 0,
132
+ usage: { inputTokens: 0, outputTokens: 0 },
133
+ filesTouched: [],
134
+ messagesSent: 0,
135
+ };
136
+ // The tools have to exist before `new Agent(...)` does, so `team_wait` and the
137
+ // confirm queue read the agent through this holder rather than capturing it.
138
+ // Resolving it eagerly would capture `null` for the child's whole life and
139
+ // silently drop the watchdog pause (see `TeamWaitDeps.getAgent`).
140
+ let agentRef = null;
141
+ const getAgent = () => agentRef;
142
+ const tools = buildSubagentTools(spec, deps, getAgent);
143
+ const model = {
144
+ providerId: deps.config.provider,
145
+ modelId: deps.config.model,
146
+ ...(deps.config.baseUrl ? { baseUrl: deps.config.baseUrl } : {}),
147
+ };
148
+ const agent = deps.agentFactory({
149
+ systemPrompt: buildSystemPrompt({
150
+ cwd: deps.getCwd(),
151
+ tools,
152
+ skillsBlock: deps.alwaysBlock ?? '',
153
+ agentMode: spec.readOnly ? 'plan' : deps.getMode(),
154
+ // A child has no overlay, so the plan block must describe the headless
155
+ // shape: write the plan as the final message rather than calling tools it
156
+ // does not have.
157
+ planInteractive: false,
158
+ subagentBlock: buildSubagentBlock({
159
+ label: spec.label,
160
+ description: spec.description,
161
+ peers: deps.bus.peersOf(spec.label),
162
+ }),
163
+ }),
164
+ model,
165
+ tools,
166
+ thinkingLevel: deps.config.thinkingLevel,
167
+ // The LEAD's registry instance, REUSED (P2-2). It holds adapter state, and
168
+ // constructing five of them per dispatch is pure waste.
169
+ providerRegistry: deps.providerRegistry,
170
+ // The lead's closure, so a settings-screen key edit reaches children too.
171
+ getApiKey: deps.getApiKey,
172
+ ...(deps.config.maxTokens !== undefined ? { maxTokens: deps.config.maxTokens } : {}),
173
+ timeouts: {
174
+ toolTimeout: deps.config.toolTimeoutMs,
175
+ // A child keeps its OWN idle watchdog, and that is a feature: a wedged
176
+ // child is aborted by its own ceiling without taking the dispatch with it
177
+ // (§3.8). The two places a child legitimately blocks on something other
178
+ // than its own LLM or tool — `team_wait` and the confirm queue — pause it
179
+ // explicitly.
180
+ idleTimeout: deps.config.idleTimeoutMs,
181
+ },
182
+ });
183
+ agentRef = agent;
184
+ let lastEmit = 0;
185
+ let lastPhase = run.phase;
186
+ // The tail of the text the child is writing right now (F-1). Bounded on every
187
+ // append: a long turn would otherwise grow this without limit, and it is the
188
+ // only per-token allocation this feature adds.
189
+ let textTail = '';
190
+ const emit = () => {
191
+ const now = Date.now();
192
+ // Phase transitions always pass; everything else is coalesced. Five children
193
+ // streaming tokens must not push five React renders per token, and the
194
+ // streaming coalescer in `App.tsx` protects the transcript, not this path.
195
+ if (run.phase === lastPhase && now - lastEmit < TEAM_LIMITS.agentUpdateThrottleMs)
196
+ return;
197
+ lastPhase = run.phase;
198
+ lastEmit = now;
199
+ // Exactly one sanitize per PUBLISHED frame rather than one per token (P2-2):
200
+ // `emit()` throws away all but one result per throttle window, so two
201
+ // regexes per token would be paid for a value nobody reads. `textTail` is
202
+ // cleared on `turn_start` and `tool_execution_end`, so an empty tail is how
203
+ // "not writing anything right now" is expressed.
204
+ run.activity =
205
+ textTail.length > 0 ? sanitizeActivity(textTail, TEAM_LIMITS.activityChars) : undefined;
206
+ hooks.onUpdate({ ...run, filesTouched: [...run.filesTouched] });
207
+ };
208
+ const noteFile = (toolName, args) => {
209
+ if (toolName !== 'write_file' && toolName !== 'edit_file')
210
+ return;
211
+ const path = args?.path;
212
+ if (typeof path !== 'string' || path.length === 0)
213
+ return;
214
+ if (run.filesTouched.includes(path))
215
+ return;
216
+ if (run.filesTouched.length >= TEAM_LIMITS.filesTouchedMax)
217
+ return;
218
+ run.filesTouched.push(path);
219
+ };
220
+ const unsubscribe = agent.subscribe((event) => {
221
+ switch (event.type) {
222
+ case 'turn_start':
223
+ run.phase = 'thinking';
224
+ textTail = '';
225
+ run.activity = undefined; // a new turn is not the last turn's work
226
+ run.activityArgs = undefined;
227
+ break;
228
+ case 'turn_end': {
229
+ run.turns += 1;
230
+ run.usage = {
231
+ inputTokens: run.usage.inputTokens + event.usage.inputTokens,
232
+ outputTokens: run.usage.outputTokens + event.usage.outputTokens,
233
+ };
234
+ hooks.onUsage(spec.label, event.usage);
235
+ const text = assistantText(event.message);
236
+ // The LAST non-empty assistant text wins, which is exactly the child's
237
+ // final message once the loop stops calling tools. Capturing it here
238
+ // rather than parsing `agent_end.messages` keeps a partial summary from
239
+ // an aborted run, which the report renders as partial rather than lost.
240
+ if (text.length > 0)
241
+ run.summary = text;
242
+ if (run.turns >= deps.config.team.maxTurnsPerSubagent) {
243
+ run.truncated = true;
244
+ agent.abort();
245
+ }
246
+ break;
247
+ }
248
+ case 'tool_execution_start':
249
+ run.phase = event.toolName === 'team_wait' ? 'waiting' : 'tool';
250
+ run.lastTool = event.toolName;
251
+ run.toolCalls += 1;
252
+ textTail = '';
253
+ run.activity = undefined; // prose is stale now
254
+ run.activityArgs = pickActivityArgs(event.args); // PICKED, not raw (P1-4)
255
+ noteFile(event.toolName, event.args);
256
+ break;
257
+ case 'tool_execution_end':
258
+ run.phase = 'thinking';
259
+ textTail = '';
260
+ run.activity = undefined;
261
+ // CLEARED, like `activity` (P1-5). Leaving it set would keep the picked
262
+ // strings alive for the whole run and past it - `publish()` copies the
263
+ // run into the snapshot and into every event, and `dispatch()` copies it
264
+ // into the outcome the transcript holds - for a value nothing renders
265
+ // once the phase is back to `thinking`.
266
+ run.activityArgs = undefined;
267
+ // Read from the BUS, not counted from tool calls: a refused send is an
268
+ // attempt, not a message, and the panel's `mail N` readout has to agree
269
+ // with what the recipients actually received.
270
+ if (event.toolName === 'team_send')
271
+ run.messagesSent = deps.bus.sentCount(spec.label);
272
+ if (event.toolName === 'team_wait') {
273
+ run.blockedWaits = deps.bus.blockedWaitCount(spec.label);
274
+ }
275
+ break;
276
+ case 'message_update':
277
+ if (event.streamEvent.type === 'error') {
278
+ run.error = formatStreamError(event.streamEvent.error);
279
+ run.retryable = isRetryableStreamError(event.streamEvent.error);
280
+ }
281
+ else if (event.streamEvent.type === 'text_delta') {
282
+ // O(1) per delta: one bounded concat and one bounded slice, and
283
+ // NOTHING else. The sanitize happens once per emitted frame instead,
284
+ // inside `emit()` above (P2-2).
285
+ textTail = (textTail + event.streamEvent.delta).slice(-TEAM_LIMITS.activityTailChars);
286
+ }
287
+ break;
288
+ default:
289
+ break;
290
+ }
291
+ emit();
292
+ });
293
+ return { spec, run, agent, tools, unsubscribe };
294
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * `task` — the delegation tool, registered on a LEAD only (team-subagents §3.3 /
3
+ * §4.1).
4
+ *
5
+ * ASCII ONLY: `src/team/**` is inside the glyph scanner's scope.
6
+ *
7
+ * THE BATCH SHAPE IS FORCED BY THE CORE LOOP, NOT CHOSEN FOR TASTE (D-1 / I-1 /
8
+ * R-1). `agent-loop.ts` executes tool calls in a `for...of` with an `await`
9
+ * inside, strictly sequentially, so N single-spec `task` calls would produce N
10
+ * SERIAL subagents — the exact opposite of this feature. Making that loop
11
+ * concurrent is not an available fix: plan mode pins the sequencing as invariant
12
+ * I-P11, because the human-input bridge holds ONE outstanding request slot and
13
+ * is correct only while tool calls never overlap. Breaking it would silently
14
+ * mis-route a plan approval to a question wizard. So the fan-out lives inside
15
+ * one tool call, where the scheduling is ours.
16
+ *
17
+ * THE SCHEMA DECLARES SHAPE, NOT POLICY (§3.3.1). Every bound lives in the
18
+ * `description` — which is what the model reads — and is enforced by
19
+ * `normalizeSubagentSpecs`, which is deterministic and unit-tested. `maxItems`
20
+ * in the schema would behave differently depending on whether `ajv`, an OPTIONAL
21
+ * dependency of core, happened to install.
22
+ */
23
+ import { type AgentTool } from '@aragon-agent/core';
24
+ import type { ModelCost } from '@aragon-agent/core';
25
+ import type { TeamRuntime } from './runtime.js';
26
+ export interface TaskToolDeps {
27
+ runtime: TeamRuntime;
28
+ /** Read LIVE, never cached: `/team off` flips this, it does not unregister. */
29
+ isTeamEnabled: () => boolean;
30
+ maxSubagents: () => number;
31
+ /** Whether a usable key exists for the active provider right now (P2-3). */
32
+ hasApiKey: () => boolean;
33
+ activeProvider: () => string;
34
+ /** The lead's cost table, so the report header states real spend (R-5). */
35
+ modelCost: () => ModelCost | undefined;
36
+ /**
37
+ * Run the dispatch with the LEAD's idle watchdog paused, `try/finally` inside.
38
+ *
39
+ * THE SINGLE MOST LIKELY WAY TO SHIP THIS FEATURE BROKEN (I-3 / R-3). The
40
+ * watchdog fires on EVENT SILENCE, not on inactivity, and a lead blocked
41
+ * inside `task` emits nothing — so a six-minute dispatch under the default
42
+ * 210 s `idleTimeoutMs` is aborted at 210 s with `[Agent] idle watchdog fired`
43
+ * on stderr and no other explanation.
44
+ */
45
+ withPausedWatchdog: <T>(fn: () => Promise<T>) => Promise<T>;
46
+ }
47
+ export declare function createTaskTool(deps: TaskToolDeps): AgentTool;
@@ -0,0 +1,118 @@
1
+ /**
2
+ * `task` — the delegation tool, registered on a LEAD only (team-subagents §3.3 /
3
+ * §4.1).
4
+ *
5
+ * ASCII ONLY: `src/team/**` is inside the glyph scanner's scope.
6
+ *
7
+ * THE BATCH SHAPE IS FORCED BY THE CORE LOOP, NOT CHOSEN FOR TASTE (D-1 / I-1 /
8
+ * R-1). `agent-loop.ts` executes tool calls in a `for...of` with an `await`
9
+ * inside, strictly sequentially, so N single-spec `task` calls would produce N
10
+ * SERIAL subagents — the exact opposite of this feature. Making that loop
11
+ * concurrent is not an available fix: plan mode pins the sequencing as invariant
12
+ * I-P11, because the human-input bridge holds ONE outstanding request slot and
13
+ * is correct only while tool calls never overlap. Breaking it would silently
14
+ * mis-route a plan approval to a question wizard. So the fan-out lives inside
15
+ * one tool call, where the scheduling is ours.
16
+ *
17
+ * THE SCHEMA DECLARES SHAPE, NOT POLICY (§3.3.1). Every bound lives in the
18
+ * `description` — which is what the model reads — and is enforced by
19
+ * `normalizeSubagentSpecs`, which is deterministic and unit-tested. `maxItems`
20
+ * in the schema would behave differently depending on whether `ajv`, an OPTIONAL
21
+ * dependency of core, happened to install.
22
+ */
23
+ import { errorResult, textResult } from '@aragon-agent/core';
24
+ import { TEAM_LIMITS } from './limits.js';
25
+ import { normalizeSubagentSpecs } from './normalize.js';
26
+ import { buildDispatchReport } from './report.js';
27
+ const BUSY_REFUSAL = 'A team dispatch is already running. Wait for its report before starting another.';
28
+ const TEAM_OFF_REFUSAL = 'Team mode is off for this session. Do this work yourself, in order.';
29
+ /**
30
+ * The model-facing description. Deliberately close to Claude Code's `Task` tool
31
+ * (R-f): what a model already knows about that transfers directly, and the one
32
+ * visible difference — the batch — is stated in the first sentence.
33
+ */
34
+ const TASK_DESCRIPTION = 'Run several independent subagents in parallel and get one combined report. Each ' +
35
+ 'subagent is a fresh agent with the same tools and working directory but NO memory of ' +
36
+ 'this conversation - its prompt must be self-contained. Use this when a task splits ' +
37
+ 'into 2 or more parts that do not depend on each other\'s output, especially reading or ' +
38
+ 'searching several areas at once. Do NOT use it when one part needs another part\'s ' +
39
+ 'result (do those yourself, in order), when two parts would edit the same file, or for ' +
40
+ 'a change small enough to just make. Subagents cannot dispatch further subagents and ' +
41
+ 'cannot ask the user anything. Give each a description of at most ' +
42
+ `${TEAM_LIMITS.descriptionChars} characters and a complete prompt. Extra entries beyond the ` +
43
+ 'configured maximum are dropped, so keep within it.';
44
+ export function createTaskTool(deps) {
45
+ return {
46
+ name: 'task',
47
+ label: 'Dispatch subagents',
48
+ description: TASK_DESCRIPTION,
49
+ parameters: {
50
+ type: 'object',
51
+ properties: {
52
+ subagents: {
53
+ type: 'array',
54
+ description: `2-5 independent subagents to run in parallel (hard maximum ${TEAM_LIMITS.hardMaxSubagents}; ` +
55
+ 'extras are dropped).',
56
+ items: {
57
+ type: 'object',
58
+ properties: {
59
+ label: {
60
+ type: 'string',
61
+ description: `Short id, <= ${TEAM_LIMITS.labelChars} chars, e.g. "api". Shown in the UI.`,
62
+ },
63
+ description: {
64
+ type: 'string',
65
+ description: `<= ${TEAM_LIMITS.descriptionChars} chars. What this subagent is for.`,
66
+ },
67
+ prompt: {
68
+ type: 'string',
69
+ description: 'The full brief. Self-contained: the subagent cannot see this conversation.',
70
+ },
71
+ readOnly: {
72
+ type: 'boolean',
73
+ default: false,
74
+ description: 'Refuse all writes and shell for this subagent.',
75
+ },
76
+ },
77
+ required: ['description', 'prompt'],
78
+ },
79
+ },
80
+ },
81
+ required: ['subagents'],
82
+ },
83
+ async execute(_id, params, ctx) {
84
+ // 1. Already dispatching. Unreachable while I-1 holds; present so the
85
+ // guarantee belongs to this module (R-14).
86
+ if (deps.runtime.isBusy())
87
+ return textResult(BUSY_REFUSAL);
88
+ // 2. Turned off mid-session. `task` is registered ONCE at construction and
89
+ // the array is immutable (§4.5 / D-17), so `/team off` cannot
90
+ // unregister it — it flips this closure instead. Read live, never
91
+ // cached.
92
+ if (!deps.isTeamEnabled())
93
+ return textResult(TEAM_OFF_REFUSAL);
94
+ // 3. No usable key (P2-3). `preflight()` runs before the LOOP, not before
95
+ // a tool call, so a key emptied through the settings screen mid-session
96
+ // would otherwise produce N identical stream failures — N times the
97
+ // cost of one refusal, and a report the model may well retry.
98
+ if (!deps.hasApiKey()) {
99
+ return textResult(`Team dispatch skipped: no API key for ${deps.activeProvider()}. Do this work ` +
100
+ 'yourself, or ask the user to set a key.');
101
+ }
102
+ // 4. Repair, never reject. Zero survivors is the single hard failure.
103
+ const { specs, requested } = normalizeSubagentSpecs(params.subagents, deps.maxSubagents());
104
+ if (specs.length === 0) {
105
+ return errorResult('No usable subagent specs: each needs a description and a prompt.');
106
+ }
107
+ // 5. Pause the lead's watchdog for the whole dispatch (I-3).
108
+ const outcome = await deps.withPausedWatchdog(() => deps.runtime.dispatch(specs, requested, ctx.signal));
109
+ // 6. ALWAYS A NON-ERROR RESULT, abort and all-children-failed included.
110
+ // A cancellation or a partial result rendered as a tool FAILURE invites
111
+ // the model to retry the whole fan-out, which is the most expensive
112
+ // possible reaction (the same reasoning as `ask_user`'s cancelled
113
+ // shape, and D-14 one level up).
114
+ const cost = deps.modelCost();
115
+ return textResult(buildDispatchReport(outcome, cost ? { cost } : {}));
116
+ },
117
+ };
118
+ }