@aragon-agent/cli 0.5.1 → 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 +117 -0
- package/README.md +155 -5
- 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 +131 -6
- 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 +36 -2
- package/dist/config/history-commands.d.ts +20 -0
- package/dist/config/history-commands.js +71 -0
- package/dist/config/load.d.ts +15 -0
- package/dist/config/load.js +88 -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 +125 -9
- package/dist/config/schema.js +90 -5
- 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/input/mouse-events.d.ts +61 -0
- package/dist/input/mouse-events.js +153 -0
- package/dist/input/stdin-mouse-filter.d.ts +42 -0
- package/dist/input/stdin-mouse-filter.js +153 -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.d.ts +9 -1
- package/dist/ui/App.js +140 -21
- package/dist/ui/Composer.d.ts +6 -1
- package/dist/ui/Composer.js +2 -2
- package/dist/ui/PromptInput.d.ts +15 -1
- package/dist/ui/PromptInput.js +38 -1
- 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 +22 -0
- package/dist/ui/glyphs.js +10 -0
- package/dist/ui/layout/AppShell.d.ts +20 -1
- package/dist/ui/layout/AppShell.js +39 -4
- package/dist/ui/layout/ScrollIndicator.d.ts +31 -0
- package/dist/ui/layout/ScrollIndicator.js +15 -0
- package/dist/ui/layout/ScrollViewport.d.ts +8 -2
- package/dist/ui/layout/ScrollViewport.js +24 -4
- package/dist/ui/layout/regions.d.ts +55 -0
- package/dist/ui/layout/regions.js +52 -0
- package/dist/ui/layout/scroll-indicator.d.ts +27 -0
- package/dist/ui/layout/scroll-indicator.js +35 -0
- package/dist/ui/layout/scroll.d.ts +9 -0
- package/dist/ui/layout/scroll.js +23 -0
- package/dist/ui/overlays/HelpOverlay.js +14 -0
- package/dist/ui/screen.d.ts +14 -1
- package/dist/ui/screen.js +37 -3
- package/dist/ui/transcript-text.js +20 -0
- package/dist/ui/use-startup-notices.d.ts +11 -1
- package/dist/ui/use-startup-notices.js +29 -2
- package/dist/ui/use-wheel-routing.d.ts +68 -0
- package/dist/ui/use-wheel-routing.js +139 -0
- package/package.json +2 -2
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The two system-prompt blocks team mode adds (team-subagents §4.6).
|
|
3
|
+
*
|
|
4
|
+
* ASCII ONLY: `src/team/**` is inside the glyph scanner's scope, and these
|
|
5
|
+
* strings also reach a model that may be running in a `cmd.exe` terminal.
|
|
6
|
+
*
|
|
7
|
+
* BOTH BLOCKS ARE SPLICED CONDITIONALLY by `buildSystemPrompt`, which is what
|
|
8
|
+
* preserves invariant I-8: with team mode off the prompt is BYTE-IDENTICAL to
|
|
9
|
+
* the pre-team output for a fixed tool array. An unconditional guidance line
|
|
10
|
+
* anywhere here would quietly break `--no-team`, `--no-skills` and the plan-mode
|
|
11
|
+
* snapshot all at once.
|
|
12
|
+
*
|
|
13
|
+
* `TEAM_BLOCK_VERSION` exists so a change to this wording is greppable from a
|
|
14
|
+
* behaviour report.
|
|
15
|
+
*/
|
|
16
|
+
import { TEAM_BLOCK_VERSION } from './limits.js';
|
|
17
|
+
export { TEAM_BLOCK_VERSION };
|
|
18
|
+
export interface TeamBlockParams {
|
|
19
|
+
maxSubagents: number;
|
|
20
|
+
maxConcurrent: number;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* The lead's `<team_mode>` block. Under 1200 characters on purpose: it is paid
|
|
24
|
+
* for on EVERY turn of every team-enabled session, so anything that does not
|
|
25
|
+
* change a delegation decision does not belong in it.
|
|
26
|
+
*
|
|
27
|
+
* "Two to four is the usual size" is where the requirement's "shu liang zi shi
|
|
28
|
+
* ying" (adaptive count) actually lives. The ceiling is mechanical and enforced
|
|
29
|
+
* elsewhere; the CHOICE is the model's, and this paragraph is the only thing
|
|
30
|
+
* that informs it.
|
|
31
|
+
*/
|
|
32
|
+
export declare function buildTeamBlock(params: TeamBlockParams): string;
|
|
33
|
+
export interface SubagentBlockParams {
|
|
34
|
+
label: string;
|
|
35
|
+
description: string;
|
|
36
|
+
/** Sibling labels, excluding this child's own. */
|
|
37
|
+
peers: string[];
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* The child's `<subagent_role>` block.
|
|
41
|
+
*
|
|
42
|
+
* "YOUR FINAL MESSAGE IS THE ONLY THING YOUR LEAD SEES" is load-bearing rather
|
|
43
|
+
* than decorative: `SubagentRun.summary` is literally the last assistant text of
|
|
44
|
+
* the child's history, so a child that signs off with "Let me know if you want
|
|
45
|
+
* more detail!" produces a useless report entry and there is no mechanism that
|
|
46
|
+
* can repair it after the fact. Wording is the only enforcement available, so it
|
|
47
|
+
* is stated in imperative terms (R-12).
|
|
48
|
+
*/
|
|
49
|
+
export declare function buildSubagentBlock(params: SubagentBlockParams): string;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The two system-prompt blocks team mode adds (team-subagents §4.6).
|
|
3
|
+
*
|
|
4
|
+
* ASCII ONLY: `src/team/**` is inside the glyph scanner's scope, and these
|
|
5
|
+
* strings also reach a model that may be running in a `cmd.exe` terminal.
|
|
6
|
+
*
|
|
7
|
+
* BOTH BLOCKS ARE SPLICED CONDITIONALLY by `buildSystemPrompt`, which is what
|
|
8
|
+
* preserves invariant I-8: with team mode off the prompt is BYTE-IDENTICAL to
|
|
9
|
+
* the pre-team output for a fixed tool array. An unconditional guidance line
|
|
10
|
+
* anywhere here would quietly break `--no-team`, `--no-skills` and the plan-mode
|
|
11
|
+
* snapshot all at once.
|
|
12
|
+
*
|
|
13
|
+
* `TEAM_BLOCK_VERSION` exists so a change to this wording is greppable from a
|
|
14
|
+
* behaviour report.
|
|
15
|
+
*/
|
|
16
|
+
import { TEAM_BLOCK_VERSION, TEAM_LIMITS } from './limits.js';
|
|
17
|
+
export { TEAM_BLOCK_VERSION };
|
|
18
|
+
/**
|
|
19
|
+
* The lead's `<team_mode>` block. Under 1200 characters on purpose: it is paid
|
|
20
|
+
* for on EVERY turn of every team-enabled session, so anything that does not
|
|
21
|
+
* change a delegation decision does not belong in it.
|
|
22
|
+
*
|
|
23
|
+
* "Two to four is the usual size" is where the requirement's "shu liang zi shi
|
|
24
|
+
* ying" (adaptive count) actually lives. The ceiling is mechanical and enforced
|
|
25
|
+
* elsewhere; the CHOICE is the model's, and this paragraph is the only thing
|
|
26
|
+
* that informs it.
|
|
27
|
+
*/
|
|
28
|
+
export function buildTeamBlock(params) {
|
|
29
|
+
const max = Math.max(1, Math.floor(params.maxSubagents));
|
|
30
|
+
const concurrent = Math.max(1, Math.min(Math.floor(params.maxConcurrent), max));
|
|
31
|
+
return [
|
|
32
|
+
'<team_mode>',
|
|
33
|
+
`You can delegate to parallel subagents with the task tool (up to ${max} per`,
|
|
34
|
+
`call, ${concurrent} running at once).`,
|
|
35
|
+
'',
|
|
36
|
+
'Delegate when: the work splits into 2+ parts that do not need each other\'s',
|
|
37
|
+
'output; several areas must be read or searched at once; a long build or test',
|
|
38
|
+
'run can proceed while another part is investigated.',
|
|
39
|
+
'',
|
|
40
|
+
'Do not delegate when: one part needs another part\'s result (do those in order',
|
|
41
|
+
'yourself); two parts would edit the same file; the whole job is a few edits.',
|
|
42
|
+
'One well-scoped subagent is better than four vague ones. Two to four is the',
|
|
43
|
+
'usual size; more than that is rarely faster.',
|
|
44
|
+
'',
|
|
45
|
+
'Each subagent starts with no memory of this conversation, so its prompt must',
|
|
46
|
+
'carry everything it needs: the goal, the files or areas it owns, and what to',
|
|
47
|
+
'report back. Give each one a disjoint set of files to write. Subagents cannot',
|
|
48
|
+
'dispatch further subagents and cannot ask the user anything.',
|
|
49
|
+
'',
|
|
50
|
+
'You get one combined report when they all finish. Read it before deciding what',
|
|
51
|
+
'to do next, and tell the user what the team found in your own words.',
|
|
52
|
+
'</team_mode>',
|
|
53
|
+
].join('\n');
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* The child's `<subagent_role>` block.
|
|
57
|
+
*
|
|
58
|
+
* "YOUR FINAL MESSAGE IS THE ONLY THING YOUR LEAD SEES" is load-bearing rather
|
|
59
|
+
* than decorative: `SubagentRun.summary` is literally the last assistant text of
|
|
60
|
+
* the child's history, so a child that signs off with "Let me know if you want
|
|
61
|
+
* more detail!" produces a useless report entry and there is no mechanism that
|
|
62
|
+
* can repair it after the fact. Wording is the only enforcement available, so it
|
|
63
|
+
* is stated in imperative terms (R-12).
|
|
64
|
+
*/
|
|
65
|
+
export function buildSubagentBlock(params) {
|
|
66
|
+
const peers = params.peers.length > 0
|
|
67
|
+
? `Teammates: ${params.peers.join(', ')}. You cannot see their conversations and they cannot see yours.`
|
|
68
|
+
: 'You are the only subagent on this task.';
|
|
69
|
+
return [
|
|
70
|
+
'<subagent_role>',
|
|
71
|
+
`You are subagent "${params.label}" on a team. Your job: ${params.description}.`,
|
|
72
|
+
peers,
|
|
73
|
+
'',
|
|
74
|
+
'Work only on your own task. Do not edit files another subagent owns. You cannot',
|
|
75
|
+
'delegate further and there is no user to ask - if something is ambiguous, choose',
|
|
76
|
+
'the most reasonable reading, proceed, and say what you assumed.',
|
|
77
|
+
'',
|
|
78
|
+
`To reach a teammate use team_send (at most ${TEAM_LIMITS.messagesPerAgent} messages, one every`,
|
|
79
|
+
`${Math.round(TEAM_LIMITS.minSendIntervalMs / 1000)} seconds). Their replies arrive attached to your next tool result.`,
|
|
80
|
+
'Use team_wait only when you genuinely cannot continue without an answer.',
|
|
81
|
+
'',
|
|
82
|
+
'END WITH YOUR REPORT. Your final message is the only thing your lead sees. It',
|
|
83
|
+
'must state: what you did, what you found, every file you changed, and anything',
|
|
84
|
+
'still open. Be concrete and brief - no preamble, no restating this brief.',
|
|
85
|
+
'</subagent_role>',
|
|
86
|
+
].join('\n');
|
|
87
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `buildDispatchReport` — the single text the lead receives back from `task`
|
|
3
|
+
* (team-subagents §3.7).
|
|
4
|
+
*
|
|
5
|
+
* ASCII ONLY: `src/team/**` is inside the glyph scanner's scope.
|
|
6
|
+
*
|
|
7
|
+
* BUDGETS ARE IN BYTES, NOT CHARACTERS (D-8 / I-9). `ToolExecutor` truncates
|
|
8
|
+
* combined tool text at 100 000 BYTES and appends `... [truncated]`; a 24 000
|
|
9
|
+
* character CJK report is 72 000 bytes, so a character-denominated cap would let
|
|
10
|
+
* the executor chop the report mid-section — and because the header is FIRST,
|
|
11
|
+
* what it would chop off is the tail, but a report assembled the other way round
|
|
12
|
+
* would lose its own failure count. Hence the second rule below.
|
|
13
|
+
*
|
|
14
|
+
* HEADER FIRST, ALWAYS. When the budget is exhausted the SUMMARIES are trimmed
|
|
15
|
+
* (longest first, each to a floor, with an explicit `[trimmed]` marker); the
|
|
16
|
+
* header, the per-agent status lines and the conflict warning are never dropped.
|
|
17
|
+
* A report that loses its own failure count is worse than one that loses prose.
|
|
18
|
+
*
|
|
19
|
+
* A FAILED CHILD NEVER FAILS THE DISPATCH (D-14). Partial results are the normal
|
|
20
|
+
* outcome of a fan-out; an all-or-nothing result would throw away four successes
|
|
21
|
+
* because of one HTTP 429.
|
|
22
|
+
*/
|
|
23
|
+
import type { ModelCost } from '@aragon-agent/core';
|
|
24
|
+
import type { DispatchOutcome, SubagentRun } from './types.js';
|
|
25
|
+
export interface ReportOptions {
|
|
26
|
+
/** The lead's model cost table, so the header can state real spend (R-5). */
|
|
27
|
+
cost?: ModelCost;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Cut `text` to at most `maxBytes` WITHOUT splitting a UTF-8 sequence.
|
|
31
|
+
*
|
|
32
|
+
* Slicing the buffer blindly at a byte offset produces a replacement character
|
|
33
|
+
* in the middle of a CJK report — the exact failure D-8 exists to prevent, just
|
|
34
|
+
* one layer lower down.
|
|
35
|
+
*/
|
|
36
|
+
export declare function truncateBytes(text: string, maxBytes: number): string;
|
|
37
|
+
/**
|
|
38
|
+
* Files written by more than one child.
|
|
39
|
+
*
|
|
40
|
+
* PHRASED AS A WARNING, NEVER AS A FACT. `filesTouched` only sees `write_file` /
|
|
41
|
+
* `edit_file`, so a child that writes through `bash` is invisible to it (R-2).
|
|
42
|
+
* "review before trusting either" is honest about that; "these two conflict"
|
|
43
|
+
* would not be.
|
|
44
|
+
*/
|
|
45
|
+
export declare function findFileConflicts(runs: SubagentRun[]): Array<{
|
|
46
|
+
path: string;
|
|
47
|
+
labels: string[];
|
|
48
|
+
}>;
|
|
49
|
+
/**
|
|
50
|
+
* Assemble the report.
|
|
51
|
+
*
|
|
52
|
+
* Trimming order: every summary starts clamped to `summaryMaxBytes`; if the
|
|
53
|
+
* whole document is still over `reportMaxBytes`, the LONGEST summary is halved
|
|
54
|
+
* repeatedly (never below `summaryFloorBytes`) until it fits or every summary is
|
|
55
|
+
* at the floor. Only then, as a last resort, are summaries dropped entirely —
|
|
56
|
+
* and even then the status lines survive, because they carry the failure count.
|
|
57
|
+
*/
|
|
58
|
+
export declare function buildDispatchReport(outcome: DispatchOutcome, options?: ReportOptions): string;
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `buildDispatchReport` — the single text the lead receives back from `task`
|
|
3
|
+
* (team-subagents §3.7).
|
|
4
|
+
*
|
|
5
|
+
* ASCII ONLY: `src/team/**` is inside the glyph scanner's scope.
|
|
6
|
+
*
|
|
7
|
+
* BUDGETS ARE IN BYTES, NOT CHARACTERS (D-8 / I-9). `ToolExecutor` truncates
|
|
8
|
+
* combined tool text at 100 000 BYTES and appends `... [truncated]`; a 24 000
|
|
9
|
+
* character CJK report is 72 000 bytes, so a character-denominated cap would let
|
|
10
|
+
* the executor chop the report mid-section — and because the header is FIRST,
|
|
11
|
+
* what it would chop off is the tail, but a report assembled the other way round
|
|
12
|
+
* would lose its own failure count. Hence the second rule below.
|
|
13
|
+
*
|
|
14
|
+
* HEADER FIRST, ALWAYS. When the budget is exhausted the SUMMARIES are trimmed
|
|
15
|
+
* (longest first, each to a floor, with an explicit `[trimmed]` marker); the
|
|
16
|
+
* header, the per-agent status lines and the conflict warning are never dropped.
|
|
17
|
+
* A report that loses its own failure count is worse than one that loses prose.
|
|
18
|
+
*
|
|
19
|
+
* A FAILED CHILD NEVER FAILS THE DISPATCH (D-14). Partial results are the normal
|
|
20
|
+
* outcome of a fan-out; an all-or-nothing result would throw away four successes
|
|
21
|
+
* because of one HTTP 429.
|
|
22
|
+
*/
|
|
23
|
+
import { computeCost, formatCost, formatDuration, formatTokens } from '../agent/usage.js';
|
|
24
|
+
import { TEAM_LIMITS } from './limits.js';
|
|
25
|
+
function byteLength(text) {
|
|
26
|
+
return Buffer.byteLength(text, 'utf8');
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Cut `text` to at most `maxBytes` WITHOUT splitting a UTF-8 sequence.
|
|
30
|
+
*
|
|
31
|
+
* Slicing the buffer blindly at a byte offset produces a replacement character
|
|
32
|
+
* in the middle of a CJK report — the exact failure D-8 exists to prevent, just
|
|
33
|
+
* one layer lower down.
|
|
34
|
+
*/
|
|
35
|
+
export function truncateBytes(text, maxBytes) {
|
|
36
|
+
if (maxBytes <= 0)
|
|
37
|
+
return '';
|
|
38
|
+
if (byteLength(text) <= maxBytes)
|
|
39
|
+
return text;
|
|
40
|
+
const buf = Buffer.from(text, 'utf8');
|
|
41
|
+
let end = Math.min(maxBytes, buf.length);
|
|
42
|
+
// A continuation byte is 0b10xxxxxx; back off until `end` starts a sequence.
|
|
43
|
+
while (end > 0 && (buf[end] !== undefined) && (buf[end] & 0xc0) === 0x80)
|
|
44
|
+
end -= 1;
|
|
45
|
+
return buf.subarray(0, end).toString('utf8');
|
|
46
|
+
}
|
|
47
|
+
/** `ok` / `aborted` / `failed: <reason>` — the bracketed badge on a section head. */
|
|
48
|
+
function statusBadge(run) {
|
|
49
|
+
if (run.phase === 'aborted')
|
|
50
|
+
return '[aborted]';
|
|
51
|
+
if (run.phase === 'failed' || run.error) {
|
|
52
|
+
return `[failed: ${run.error ?? 'run produced no output'}]`;
|
|
53
|
+
}
|
|
54
|
+
if (run.truncated)
|
|
55
|
+
return '[stopped: turn cap reached]';
|
|
56
|
+
return '[ok]';
|
|
57
|
+
}
|
|
58
|
+
function elapsed(run) {
|
|
59
|
+
if (run.startedAt === undefined)
|
|
60
|
+
return '0.0s';
|
|
61
|
+
return formatDuration(Math.max(0, (run.endedAt ?? run.startedAt) - run.startedAt));
|
|
62
|
+
}
|
|
63
|
+
function isOk(run) {
|
|
64
|
+
return run.phase === 'done' && !run.error;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Files written by more than one child.
|
|
68
|
+
*
|
|
69
|
+
* PHRASED AS A WARNING, NEVER AS A FACT. `filesTouched` only sees `write_file` /
|
|
70
|
+
* `edit_file`, so a child that writes through `bash` is invisible to it (R-2).
|
|
71
|
+
* "review before trusting either" is honest about that; "these two conflict"
|
|
72
|
+
* would not be.
|
|
73
|
+
*/
|
|
74
|
+
export function findFileConflicts(runs) {
|
|
75
|
+
const owners = new Map();
|
|
76
|
+
for (const run of runs) {
|
|
77
|
+
for (const path of run.filesTouched) {
|
|
78
|
+
const list = owners.get(path) ?? [];
|
|
79
|
+
if (!list.includes(run.label))
|
|
80
|
+
list.push(run.label);
|
|
81
|
+
owners.set(path, list);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
const out = [];
|
|
85
|
+
for (const [path, labels] of owners) {
|
|
86
|
+
if (labels.length > 1)
|
|
87
|
+
out.push({ path, labels });
|
|
88
|
+
}
|
|
89
|
+
return out.sort((a, b) => a.path.localeCompare(b.path, 'en'));
|
|
90
|
+
}
|
|
91
|
+
function buildHeader(outcome, options) {
|
|
92
|
+
const lines = [];
|
|
93
|
+
const durationMs = Math.max(0, outcome.endedAt - outcome.startedAt);
|
|
94
|
+
if (outcome.aborted) {
|
|
95
|
+
// FIRST LINE on an abort, so the model cannot present partial work as
|
|
96
|
+
// complete without having read past it.
|
|
97
|
+
lines.push(`Team dispatch ABORTED after ${formatDuration(durationMs)}; partial results below.`);
|
|
98
|
+
}
|
|
99
|
+
const ok = outcome.runs.filter(isOk).length;
|
|
100
|
+
const failed = outcome.runs.length - ok;
|
|
101
|
+
const counts = `${ok} ok, ${failed} failed`;
|
|
102
|
+
lines.push(outcome.requested > outcome.runs.length
|
|
103
|
+
? `Team dispatch: ran ${outcome.runs.length} of ${outcome.requested} requested subagents ` +
|
|
104
|
+
`(${counts}) in ${formatDuration(durationMs)}.`
|
|
105
|
+
: `Team dispatch: ${outcome.runs.length} of ${outcome.runs.length} subagents finished ` +
|
|
106
|
+
`(${counts}) in ${formatDuration(durationMs)}.`);
|
|
107
|
+
lines.push(`Tokens: in ${formatTokens(outcome.usage.inputTokens)}, out ` +
|
|
108
|
+
`${formatTokens(outcome.usage.outputTokens)}. Cost: ${formatCost(computeCost(outcome.usage, options.cost))}.`);
|
|
109
|
+
for (const conflict of findFileConflicts(outcome.runs)) {
|
|
110
|
+
const who = conflict.labels.map((l) => `"${l}"`).join(' and ');
|
|
111
|
+
lines.push(`WARNING: agents ${who} both wrote ${conflict.path} - review before trusting either.`);
|
|
112
|
+
}
|
|
113
|
+
return lines;
|
|
114
|
+
}
|
|
115
|
+
function buildSection(run) {
|
|
116
|
+
// `retried Nx` on the head line, which is never trimmed. 11 bytes, and it is
|
|
117
|
+
// the only place the lead learns that a child's first attempt died before it
|
|
118
|
+
// did anything (F-4).
|
|
119
|
+
const retried = run.retries ? `, retried ${run.retries}x` : '';
|
|
120
|
+
const head = [
|
|
121
|
+
`### ${run.label} "${run.description}" ${statusBadge(run)} ${elapsed(run)}, ` +
|
|
122
|
+
`${run.turns} turns, ${run.toolCalls} tools${retried}`,
|
|
123
|
+
];
|
|
124
|
+
if (run.filesTouched.length > 0)
|
|
125
|
+
head.push(`files: ${run.filesTouched.join(', ')}`);
|
|
126
|
+
// A child that tried to reach a teammate and could not is a child whose brief
|
|
127
|
+
// had a dependency the lead should not have split (F-3). One line, in a
|
|
128
|
+
// section that is trimmed last, and only when it actually happened.
|
|
129
|
+
if (run.blockedWaits)
|
|
130
|
+
head.push(`blocked waits: ${run.blockedWaits} (no teammate could answer)`);
|
|
131
|
+
const summary = (run.summary ?? '').trim();
|
|
132
|
+
return { head, summary: summary.length > 0 ? summary : '(no summary)' };
|
|
133
|
+
}
|
|
134
|
+
function buildLeadMail(outcome) {
|
|
135
|
+
if (outcome.leadMail.length === 0)
|
|
136
|
+
return [];
|
|
137
|
+
const lines = ['### Messages to you'];
|
|
138
|
+
for (const m of outcome.leadMail) {
|
|
139
|
+
const at = Math.max(0, Math.round((m.at - outcome.startedAt) / 1000));
|
|
140
|
+
lines.push(`[${m.from} -> lead] "${m.subject}" (${at}s in)`);
|
|
141
|
+
if (m.body.length > 0)
|
|
142
|
+
lines.push(m.body);
|
|
143
|
+
}
|
|
144
|
+
return lines;
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Assemble the report.
|
|
148
|
+
*
|
|
149
|
+
* Trimming order: every summary starts clamped to `summaryMaxBytes`; if the
|
|
150
|
+
* whole document is still over `reportMaxBytes`, the LONGEST summary is halved
|
|
151
|
+
* repeatedly (never below `summaryFloorBytes`) until it fits or every summary is
|
|
152
|
+
* at the floor. Only then, as a last resort, are summaries dropped entirely —
|
|
153
|
+
* and even then the status lines survive, because they carry the failure count.
|
|
154
|
+
*/
|
|
155
|
+
export function buildDispatchReport(outcome, options = {}) {
|
|
156
|
+
const header = buildHeader(outcome, options);
|
|
157
|
+
const sections = outcome.runs.map(buildSection);
|
|
158
|
+
const mail = buildLeadMail(outcome);
|
|
159
|
+
const summaries = sections.map((s) => truncateBytes(s.summary, TEAM_LIMITS.summaryMaxBytes));
|
|
160
|
+
const trimmed = summaries.map((s, i) => s.length < sections[i].summary.length);
|
|
161
|
+
const render = (bodies) => {
|
|
162
|
+
const blocks = [header.join('\n')];
|
|
163
|
+
sections.forEach((section, i) => {
|
|
164
|
+
const body = bodies[i];
|
|
165
|
+
const lines = [...section.head];
|
|
166
|
+
if (body !== null && body !== undefined) {
|
|
167
|
+
lines.push(trimmed[i] ? `${body}\n[trimmed]` : body);
|
|
168
|
+
}
|
|
169
|
+
blocks.push(lines.join('\n'));
|
|
170
|
+
});
|
|
171
|
+
if (mail.length > 0)
|
|
172
|
+
blocks.push(mail.join('\n'));
|
|
173
|
+
return blocks.join('\n\n');
|
|
174
|
+
};
|
|
175
|
+
let bodies = [...summaries];
|
|
176
|
+
let out = render(bodies);
|
|
177
|
+
let guard = 0;
|
|
178
|
+
while (byteLength(out) > TEAM_LIMITS.reportMaxBytes && guard < 200) {
|
|
179
|
+
guard += 1;
|
|
180
|
+
let longest = -1;
|
|
181
|
+
let longestBytes = TEAM_LIMITS.summaryFloorBytes;
|
|
182
|
+
bodies.forEach((body, i) => {
|
|
183
|
+
if (body === null)
|
|
184
|
+
return;
|
|
185
|
+
const size = byteLength(body);
|
|
186
|
+
if (size > longestBytes) {
|
|
187
|
+
longest = i;
|
|
188
|
+
longestBytes = size;
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
if (longest < 0)
|
|
192
|
+
break; // Every summary is at the floor.
|
|
193
|
+
const next = Math.max(TEAM_LIMITS.summaryFloorBytes, Math.floor(longestBytes / 2));
|
|
194
|
+
bodies[longest] = truncateBytes(bodies[longest], next);
|
|
195
|
+
trimmed[longest] = true;
|
|
196
|
+
out = render(bodies);
|
|
197
|
+
}
|
|
198
|
+
if (byteLength(out) > TEAM_LIMITS.reportMaxBytes) {
|
|
199
|
+
// Last resort. The header, the status lines and the conflict warning are
|
|
200
|
+
// still here, which is the whole point of assembling them separately.
|
|
201
|
+
bodies = bodies.map(() => null);
|
|
202
|
+
out = render(bodies);
|
|
203
|
+
}
|
|
204
|
+
return out;
|
|
205
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The cold-start retry decision (team-live-activity F-4).
|
|
3
|
+
*
|
|
4
|
+
* ASCII ONLY: `src/team/**` is inside the glyph scanner's scope.
|
|
5
|
+
*
|
|
6
|
+
* RETRY IS SAFE ONLY WHEN THE CHILD PROVABLY DID NOTHING. `turns === 0 &&
|
|
7
|
+
* toolCalls === 0` is the side-effect proof: no turn completed, so nothing was
|
|
8
|
+
* written to the transcript, and no tool ran, so nothing was written to disk.
|
|
9
|
+
* Everything else about the child is fresh by construction.
|
|
10
|
+
*
|
|
11
|
+
* THE TRAP IS THAT EVERY ABORT LOOKS RETRYABLE. `wrapFetchError` in
|
|
12
|
+
* `packages/core` classifies an `AbortError` as `errorType: 'timeout'` with
|
|
13
|
+
* `retryable: true`, and every abort in this system reaches the provider as an
|
|
14
|
+
* aborted fetch - so a user pressing Esc produces a "retryable" error. The
|
|
15
|
+
* predicate below does NOT see that, and must not be asked to: `TeamRuntime`
|
|
16
|
+
* excludes the abort paths before it ever calls the predicate, and there are
|
|
17
|
+
* exactly four of them (user Esc / `AgentController.abort()`, the `task` tool's
|
|
18
|
+
* `ctx.signal`, the dispatch timeout - all three via `this.aborted` - and the
|
|
19
|
+
* per-child timeout via the local `timedOut` flag). The turn-cap abort is
|
|
20
|
+
* excluded twice over, by `run.truncated` here and by `turns === 0`.
|
|
21
|
+
*
|
|
22
|
+
* There is a fifth guard and it is DEFENCE IN DEPTH, NOT A SUBSTITUTE. The core
|
|
23
|
+
* agent loop tests `ctx.signal.aborted` at the top of every stream iteration and
|
|
24
|
+
* breaks BEFORE emitting `message_update`, so on an aborted request the
|
|
25
|
+
* provider's `error` event never reaches a subscriber and `run.retryable` is
|
|
26
|
+
* never even set. That is a property of a `break` in another package that no
|
|
27
|
+
* test in this repo pins; it could change under a refactor of core's steering
|
|
28
|
+
* handling without anyone here noticing. The four exclusions stay, and the
|
|
29
|
+
* acceptance test keeps asserting all four from this side of the boundary.
|
|
30
|
+
*/
|
|
31
|
+
import type { SubagentRun } from './types.js';
|
|
32
|
+
/**
|
|
33
|
+
* Read `retryable` STRUCTURALLY rather than with `instanceof LLMError`.
|
|
34
|
+
*
|
|
35
|
+
* The error is constructed inside `packages/core` and crosses a package
|
|
36
|
+
* boundary; a duplicated core instance (a linked checkout, a hoisting accident)
|
|
37
|
+
* would make `instanceof` false while the field is plainly there, and the
|
|
38
|
+
* failure mode would be "retries silently stopped happening". `formatStreamError`
|
|
39
|
+
* reads `errorType` exactly this way for the same reason (reducer.ts).
|
|
40
|
+
*/
|
|
41
|
+
export declare function isRetryableStreamError(err: unknown): boolean;
|
|
42
|
+
/**
|
|
43
|
+
* Whether this attempt may be replaced by a fresh child.
|
|
44
|
+
*
|
|
45
|
+
* `attemptsSoFar` counts the attempt that just ended, so the first failure calls
|
|
46
|
+
* this with `1` and `maxColdStartRetries` of 1 permits exactly one replacement.
|
|
47
|
+
*/
|
|
48
|
+
export declare function shouldRetryColdStart(run: SubagentRun, attemptsSoFar: number): boolean;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The cold-start retry decision (team-live-activity F-4).
|
|
3
|
+
*
|
|
4
|
+
* ASCII ONLY: `src/team/**` is inside the glyph scanner's scope.
|
|
5
|
+
*
|
|
6
|
+
* RETRY IS SAFE ONLY WHEN THE CHILD PROVABLY DID NOTHING. `turns === 0 &&
|
|
7
|
+
* toolCalls === 0` is the side-effect proof: no turn completed, so nothing was
|
|
8
|
+
* written to the transcript, and no tool ran, so nothing was written to disk.
|
|
9
|
+
* Everything else about the child is fresh by construction.
|
|
10
|
+
*
|
|
11
|
+
* THE TRAP IS THAT EVERY ABORT LOOKS RETRYABLE. `wrapFetchError` in
|
|
12
|
+
* `packages/core` classifies an `AbortError` as `errorType: 'timeout'` with
|
|
13
|
+
* `retryable: true`, and every abort in this system reaches the provider as an
|
|
14
|
+
* aborted fetch - so a user pressing Esc produces a "retryable" error. The
|
|
15
|
+
* predicate below does NOT see that, and must not be asked to: `TeamRuntime`
|
|
16
|
+
* excludes the abort paths before it ever calls the predicate, and there are
|
|
17
|
+
* exactly four of them (user Esc / `AgentController.abort()`, the `task` tool's
|
|
18
|
+
* `ctx.signal`, the dispatch timeout - all three via `this.aborted` - and the
|
|
19
|
+
* per-child timeout via the local `timedOut` flag). The turn-cap abort is
|
|
20
|
+
* excluded twice over, by `run.truncated` here and by `turns === 0`.
|
|
21
|
+
*
|
|
22
|
+
* There is a fifth guard and it is DEFENCE IN DEPTH, NOT A SUBSTITUTE. The core
|
|
23
|
+
* agent loop tests `ctx.signal.aborted` at the top of every stream iteration and
|
|
24
|
+
* breaks BEFORE emitting `message_update`, so on an aborted request the
|
|
25
|
+
* provider's `error` event never reaches a subscriber and `run.retryable` is
|
|
26
|
+
* never even set. That is a property of a `break` in another package that no
|
|
27
|
+
* test in this repo pins; it could change under a refactor of core's steering
|
|
28
|
+
* handling without anyone here noticing. The four exclusions stay, and the
|
|
29
|
+
* acceptance test keeps asserting all four from this side of the boundary.
|
|
30
|
+
*/
|
|
31
|
+
import { TEAM_LIMITS } from './limits.js';
|
|
32
|
+
/**
|
|
33
|
+
* Read `retryable` STRUCTURALLY rather than with `instanceof LLMError`.
|
|
34
|
+
*
|
|
35
|
+
* The error is constructed inside `packages/core` and crosses a package
|
|
36
|
+
* boundary; a duplicated core instance (a linked checkout, a hoisting accident)
|
|
37
|
+
* would make `instanceof` false while the field is plainly there, and the
|
|
38
|
+
* failure mode would be "retries silently stopped happening". `formatStreamError`
|
|
39
|
+
* reads `errorType` exactly this way for the same reason (reducer.ts).
|
|
40
|
+
*/
|
|
41
|
+
export function isRetryableStreamError(err) {
|
|
42
|
+
return err?.retryable === true;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Whether this attempt may be replaced by a fresh child.
|
|
46
|
+
*
|
|
47
|
+
* `attemptsSoFar` counts the attempt that just ended, so the first failure calls
|
|
48
|
+
* this with `1` and `maxColdStartRetries` of 1 permits exactly one replacement.
|
|
49
|
+
*/
|
|
50
|
+
export function shouldRetryColdStart(run, attemptsSoFar) {
|
|
51
|
+
return (attemptsSoFar <= TEAM_LIMITS.maxColdStartRetries &&
|
|
52
|
+
run.retryable === true &&
|
|
53
|
+
run.turns === 0 &&
|
|
54
|
+
run.toolCalls === 0 &&
|
|
55
|
+
!run.truncated);
|
|
56
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TeamRuntime — dispatch scheduling, budgets, the `TeamEvent` stream, abort and
|
|
3
|
+
* disposal (team-subagents §3.2 / §3.5 / §3.8).
|
|
4
|
+
*
|
|
5
|
+
* ASCII ONLY: `src/team/**` is inside the glyph scanner's scope.
|
|
6
|
+
*
|
|
7
|
+
* ALL CONCURRENCY LIVES INSIDE ONE TOOL EXECUTION (I-1 / D-1). The core agent
|
|
8
|
+
* loop runs tool calls strictly sequentially, and that is not an accident to be
|
|
9
|
+
* "fixed": plan mode's human-input bridge holds ONE outstanding request slot and
|
|
10
|
+
* is correct only because tool calls never overlap (I-P11). So `task` takes a
|
|
11
|
+
* BATCH and the fan-out is scheduled here, where it is ours to control. Anyone
|
|
12
|
+
* tempted to make `agent-loop.ts` concurrent to "fix" parallel delegation is
|
|
13
|
+
* about to silently mis-route a plan approval to a question wizard.
|
|
14
|
+
*
|
|
15
|
+
* The scheduler is a plain slot pool: an index cursor plus `maxConcurrent`
|
|
16
|
+
* worker loops, awaited with `Promise.allSettled`. No worker abstraction, no
|
|
17
|
+
* queue class, no event-loop trickery — every path is observable from the event
|
|
18
|
+
* stream.
|
|
19
|
+
*/
|
|
20
|
+
import type { ProviderRegistry, SkillRegistry, ToolPolicyDecision, ToolPolicyVerdict } from '@aragon-agent/core';
|
|
21
|
+
import type { AgentMode } from '../agent/agent-mode.js';
|
|
22
|
+
import type { CliConfig } from '../config/schema.js';
|
|
23
|
+
import type { TeamHumanQueue } from './human-queue.js';
|
|
24
|
+
import { type SubagentAgentFactory } from './subagent.js';
|
|
25
|
+
import type { DispatchOutcome, SubagentSpec, TeamEventListener, TeamSnapshot } from './types.js';
|
|
26
|
+
export interface TeamRuntimeDeps {
|
|
27
|
+
/** Read LIVE: a settings-screen model or key edit must reach the next dispatch. */
|
|
28
|
+
getConfig: () => CliConfig;
|
|
29
|
+
providerRegistry: ProviderRegistry;
|
|
30
|
+
getCwd: () => string;
|
|
31
|
+
getMode: () => AgentMode;
|
|
32
|
+
getApiKey: (providerId: string) => string | undefined;
|
|
33
|
+
skillRegistry?: SkillRegistry;
|
|
34
|
+
alwaysBlock?: () => string;
|
|
35
|
+
toolPolicy?: () => ToolPolicyDecision;
|
|
36
|
+
onChildToolPolicyEvent?: (e: {
|
|
37
|
+
label: string;
|
|
38
|
+
tool: string;
|
|
39
|
+
verdict: ToolPolicyVerdict;
|
|
40
|
+
sourceNames: string[];
|
|
41
|
+
}) => void;
|
|
42
|
+
humanQueue?: TeamHumanQueue;
|
|
43
|
+
/** Test seam (§8.1): a stub factory keeps the whole scheduler off the network. */
|
|
44
|
+
agentFactory?: SubagentAgentFactory;
|
|
45
|
+
}
|
|
46
|
+
export declare class TeamRuntime {
|
|
47
|
+
private readonly deps;
|
|
48
|
+
private readonly listeners;
|
|
49
|
+
private live;
|
|
50
|
+
private bus;
|
|
51
|
+
private snapshotState;
|
|
52
|
+
private busy;
|
|
53
|
+
private aborted;
|
|
54
|
+
private counter;
|
|
55
|
+
private dispatchTimer;
|
|
56
|
+
/**
|
|
57
|
+
* Every in-flight retry backoff, so an abort releases ALL of them (P1-7).
|
|
58
|
+
*
|
|
59
|
+
* A SET, NOT ONE FIELD, and the plural is load-bearing: the failure that
|
|
60
|
+
* triggers a cold start is usually provider-wide, so a 429 fails every
|
|
61
|
+
* in-flight child's first request in the same tick and up to `maxConcurrent`
|
|
62
|
+
* children enter backoff together. With a single stored resolver two of three
|
|
63
|
+
* are overwritten and only the last is released, so "an abort during the
|
|
64
|
+
* backoff window ends the dispatch promptly" would hold for one child and
|
|
65
|
+
* quietly not for the others.
|
|
66
|
+
*/
|
|
67
|
+
private readonly pendingBackoffs;
|
|
68
|
+
constructor(deps: TeamRuntimeDeps);
|
|
69
|
+
subscribe(listener: TeamEventListener): () => void;
|
|
70
|
+
/**
|
|
71
|
+
* Unreachable while I-1 holds, and present anyway so "only one dispatch at a
|
|
72
|
+
* time" is a property of THIS module rather than a consequence of another
|
|
73
|
+
* package's loop shape (R-14).
|
|
74
|
+
*/
|
|
75
|
+
isBusy(): boolean;
|
|
76
|
+
/** The live roster, for `/team` status. `null` when nothing is dispatching. */
|
|
77
|
+
snapshot(): TeamSnapshot | null;
|
|
78
|
+
dispatch(specs: SubagentSpec[], requested: number, signal?: AbortSignal): Promise<DispatchOutcome>;
|
|
79
|
+
/** One slot of the pool: take the next index until the list is exhausted. */
|
|
80
|
+
private worker;
|
|
81
|
+
/**
|
|
82
|
+
* Run one child to a terminal phase, replacing it once if its very first
|
|
83
|
+
* request died on a transport error before it did anything at all (F-4).
|
|
84
|
+
*/
|
|
85
|
+
private runOne;
|
|
86
|
+
/**
|
|
87
|
+
* Swap a cold-started child for a fresh one, carrying only what must survive.
|
|
88
|
+
*
|
|
89
|
+
* `this.live` IS `handles`, so the single assignment below also makes
|
|
90
|
+
* `abortAll()` abort the NEW child and makes the outcome carry the new run.
|
|
91
|
+
* Anyone tempted to "clean up" that aliasing into a defensive copy must update
|
|
92
|
+
* both places.
|
|
93
|
+
*/
|
|
94
|
+
private replaceForRetry;
|
|
95
|
+
/**
|
|
96
|
+
* The final phase of an attempt, in a FIXED ORDER: aborted, then timed out,
|
|
97
|
+
* then error, then no-summary, then done.
|
|
98
|
+
*
|
|
99
|
+
* Extracted verbatim so the retry loop has one exit and the order lives in one
|
|
100
|
+
* place.
|
|
101
|
+
*/
|
|
102
|
+
private settlePhase;
|
|
103
|
+
/**
|
|
104
|
+
* Sleep, releasing early when the dispatch aborts.
|
|
105
|
+
*
|
|
106
|
+
* The timer is `unref`'d so a pending backoff cannot hold the process open on
|
|
107
|
+
* exit, and the resolver is registered in `pendingBackoffs` so `abortAll()`
|
|
108
|
+
* releases EVERY one of them rather than only the last (P1-7).
|
|
109
|
+
*/
|
|
110
|
+
private backoff;
|
|
111
|
+
/**
|
|
112
|
+
* Stop every child and settle every blocking wait.
|
|
113
|
+
*
|
|
114
|
+
* Idempotent, and safe to call when nothing is running — `AgentController.abort()`
|
|
115
|
+
* calls it unconditionally.
|
|
116
|
+
*/
|
|
117
|
+
abortAll(): void;
|
|
118
|
+
/** Process-exit path: abort everything and clear timers (R-15). */
|
|
119
|
+
dispose(): void;
|
|
120
|
+
private publish;
|
|
121
|
+
private emit;
|
|
122
|
+
}
|