@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.
- package/CHANGELOG.md +90 -0
- package/README.md +128 -1
- package/dist/agent/controller.d.ts +51 -1
- package/dist/agent/controller.js +177 -2
- package/dist/agent/headless.d.ts +12 -0
- package/dist/agent/headless.js +41 -1
- package/dist/agent/reducer.d.ts +48 -0
- package/dist/agent/reducer.js +91 -2
- package/dist/agent/system-prompt.d.ts +17 -0
- package/dist/agent/system-prompt.js +4 -0
- package/dist/cli.js +66 -2
- package/dist/commands/builtins.js +82 -0
- package/dist/config/app-paths.d.ts +17 -0
- package/dist/config/app-paths.js +21 -0
- package/dist/config/cli-commands.d.ts +26 -2
- package/dist/config/cli-commands.js +63 -4
- package/dist/config/env.d.ts +2 -2
- package/dist/config/env.js +28 -2
- package/dist/config/history-commands.d.ts +20 -0
- package/dist/config/history-commands.js +71 -0
- package/dist/config/load.d.ts +13 -0
- package/dist/config/load.js +65 -5
- package/dist/config/migrate-state-out-of-config.d.ts +32 -0
- package/dist/config/migrate-state-out-of-config.js +85 -0
- package/dist/config/prompt-history.d.ts +86 -0
- package/dist/config/prompt-history.js +332 -0
- package/dist/config/schema.d.ts +112 -16
- package/dist/config/schema.js +89 -6
- package/dist/config/store.js +13 -4
- package/dist/config/ui-state.d.ts +52 -0
- package/dist/config/ui-state.js +149 -0
- package/dist/logging/install.d.ts +85 -0
- package/dist/logging/install.js +94 -0
- package/dist/logging/logger.d.ts +1 -1
- package/dist/session/persist.d.ts +16 -0
- package/dist/session/persist.js +19 -1
- package/dist/team/activity.d.ts +67 -0
- package/dist/team/activity.js +130 -0
- package/dist/team/bus.d.ts +190 -0
- package/dist/team/bus.js +366 -0
- package/dist/team/comm-tools.d.ts +61 -0
- package/dist/team/comm-tools.js +221 -0
- package/dist/team/human-queue.d.ts +55 -0
- package/dist/team/human-queue.js +72 -0
- package/dist/team/limits.d.ts +123 -0
- package/dist/team/limits.js +128 -0
- package/dist/team/normalize.d.ts +39 -0
- package/dist/team/normalize.js +106 -0
- package/dist/team/panel-rows.d.ts +48 -0
- package/dist/team/panel-rows.js +75 -0
- package/dist/team/prompt.d.ts +49 -0
- package/dist/team/prompt.js +87 -0
- package/dist/team/report.d.ts +58 -0
- package/dist/team/report.js +205 -0
- package/dist/team/retry.d.ts +48 -0
- package/dist/team/retry.js +56 -0
- package/dist/team/runtime.d.ts +122 -0
- package/dist/team/runtime.js +406 -0
- package/dist/team/subagent.d.ts +90 -0
- package/dist/team/subagent.js +294 -0
- package/dist/team/task-tool.d.ts +47 -0
- package/dist/team/task-tool.js +118 -0
- package/dist/team/types.d.ts +172 -0
- package/dist/team/types.js +13 -0
- package/dist/tools/index.d.ts +48 -2
- package/dist/tools/index.js +26 -1
- package/dist/ui/App.js +97 -13
- package/dist/ui/StatusBar.d.ts +21 -0
- package/dist/ui/StatusBar.js +5 -2
- package/dist/ui/TeamPanel.d.ts +57 -0
- package/dist/ui/TeamPanel.js +134 -0
- package/dist/ui/Transcript.js +10 -0
- package/dist/ui/entries/TeamCard.d.ts +31 -0
- package/dist/ui/entries/TeamCard.js +39 -0
- package/dist/ui/glyphs.d.ts +10 -0
- package/dist/ui/glyphs.js +6 -0
- package/dist/ui/layout/AppShell.d.ts +11 -1
- package/dist/ui/layout/AppShell.js +3 -3
- package/dist/ui/overlays/HelpOverlay.js +12 -0
- package/dist/ui/transcript-text.js +20 -0
- package/dist/ui/use-startup-notices.d.ts +1 -1
- package/dist/ui/use-startup-notices.js +2 -1
- 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
|
+
}
|