@aragon-agent/cli 0.5.2 → 0.5.4
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 +127 -0
- package/README.md +193 -3
- 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 +98 -7
- package/dist/commands/builtins.d.ts +2 -2
- package/dist/commands/builtins.js +133 -3
- 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 +83 -6
- package/dist/config/env.d.ts +3 -2
- package/dist/config/env.js +44 -6
- 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 +107 -6
- 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 +176 -18
- package/dist/config/schema.js +162 -8
- package/dist/config/store.js +40 -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.d.ts +22 -1
- package/dist/ui/App.js +144 -18
- 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 +13 -0
- package/dist/ui/overlays/SettingsScreen.d.ts +12 -1
- package/dist/ui/overlays/SettingsScreen.js +38 -1
- 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
package/dist/team/bus.js
ADDED
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TeamBus — the subagent message channel (team-subagents §3.6).
|
|
3
|
+
*
|
|
4
|
+
* ASCII ONLY: `src/team/**` is inside the glyph scanner's scope.
|
|
5
|
+
*
|
|
6
|
+
* The requirement asks for "lei si ren lei jiao liu de dian hua ji zhi, dan bu
|
|
7
|
+
* neng tai pin fan" — a real telephone, used sparingly. This module takes both
|
|
8
|
+
* halves literally: there IS a channel, and it is expensive on purpose. The rate
|
|
9
|
+
* limits in `TEAM_LIMITS` are the requirement, not a compromise (R-9).
|
|
10
|
+
*
|
|
11
|
+
* DELIVERY IS PIGGY-BACKED, NEVER PUSHED (D-4). A message for child B is
|
|
12
|
+
* appended to B's NEXT TOOL RESULT by `withMailboxTail`. `Agent.steer()` looks
|
|
13
|
+
* like the natural push channel and is the wrong answer: the core agent loop
|
|
14
|
+
* treats steering that arrives mid-batch by pushing "Tool execution skipped due
|
|
15
|
+
* to steering interrupt." for EVERY REMAINING TOOL CALL in that batch, so a
|
|
16
|
+
* chatty peer would cancel its colleague's work as a side effect of saying
|
|
17
|
+
* hello. Piggy-backing costs zero extra turns, cannot interfere with the loop,
|
|
18
|
+
* and is guaranteed to be seen by any child that is doing anything at all.
|
|
19
|
+
*
|
|
20
|
+
* EVERY REFUSAL IS A NON-ERROR (D-7). An `errorResult` reads to a model as a
|
|
21
|
+
* malfunction and invites an immediate retry, which is precisely the "tai pin
|
|
22
|
+
* fan" behaviour being designed out. The verdicts below carry a `reason` the
|
|
23
|
+
* caller renders as ordinary text.
|
|
24
|
+
*/
|
|
25
|
+
import { TEAM_LIMITS } from './limits.js';
|
|
26
|
+
/** The lead's stable address. Subagents are addressed by their `label`. */
|
|
27
|
+
export const LEAD_KEY = 'lead';
|
|
28
|
+
/** Broadcast address: every other participant, the lead included. */
|
|
29
|
+
export const ALL_KEY = 'all';
|
|
30
|
+
export class TeamBus {
|
|
31
|
+
labels;
|
|
32
|
+
mailboxes = new Map();
|
|
33
|
+
waiters = new Set();
|
|
34
|
+
sentBy = new Map();
|
|
35
|
+
lastSentAt = new Map();
|
|
36
|
+
blocked = new Map();
|
|
37
|
+
lead = [];
|
|
38
|
+
now;
|
|
39
|
+
onMessage;
|
|
40
|
+
canSend;
|
|
41
|
+
total = 0;
|
|
42
|
+
last;
|
|
43
|
+
constructor(labels, options = {}) {
|
|
44
|
+
this.labels = [...labels];
|
|
45
|
+
this.now = options.now ?? (() => Date.now());
|
|
46
|
+
if (options.onMessage)
|
|
47
|
+
this.onMessage = options.onMessage;
|
|
48
|
+
this.canSend = options.canSend ?? (() => true);
|
|
49
|
+
for (const label of this.labels)
|
|
50
|
+
this.mailboxes.set(label, []);
|
|
51
|
+
}
|
|
52
|
+
/** Every addressable participant, in dispatch order, with the lead last. */
|
|
53
|
+
participants() {
|
|
54
|
+
return [...this.labels, LEAD_KEY];
|
|
55
|
+
}
|
|
56
|
+
/** Sibling labels for the `<subagent_role>` block. */
|
|
57
|
+
peersOf(label) {
|
|
58
|
+
return this.labels.filter((l) => l !== label);
|
|
59
|
+
}
|
|
60
|
+
/** Messages addressed to `lead`, for the report's own section (§3.7). */
|
|
61
|
+
leadMail() {
|
|
62
|
+
return [...this.lead];
|
|
63
|
+
}
|
|
64
|
+
messageCount() {
|
|
65
|
+
return this.total;
|
|
66
|
+
}
|
|
67
|
+
lastMessage() {
|
|
68
|
+
return this.last;
|
|
69
|
+
}
|
|
70
|
+
sentCount(label) {
|
|
71
|
+
return this.sentBy.get(label) ?? 0;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Canonical label for a model-supplied `from`, or `undefined` (P1-3).
|
|
75
|
+
*
|
|
76
|
+
* PUBLIC, because `makeTeamWait` has to hand the SAME string to
|
|
77
|
+
* `checkWaitable` and to `wait()`. `wait()` and `deliverToWaiter` compare
|
|
78
|
+
* `m.from === opts.from` EXACTLY, so a caller that gate-checks `"A2"`
|
|
79
|
+
* case-insensitively and then passes `"A2"` down would wait the full timeout
|
|
80
|
+
* for a message the delivery filter can never match.
|
|
81
|
+
*/
|
|
82
|
+
resolveLabel(raw) {
|
|
83
|
+
const t = raw.trim().toLowerCase();
|
|
84
|
+
return this.labels.find((l) => l.toLowerCase() === t);
|
|
85
|
+
}
|
|
86
|
+
/** `team_wait` calls refused as unanswerable, per label (F-3). */
|
|
87
|
+
recordBlockedWait(label) {
|
|
88
|
+
this.blocked.set(label, this.blockedWaitCount(label) + 1);
|
|
89
|
+
}
|
|
90
|
+
blockedWaitCount(label) {
|
|
91
|
+
return this.blocked.get(label) ?? 0;
|
|
92
|
+
}
|
|
93
|
+
// -------------------------------------------------------------------------
|
|
94
|
+
// Send
|
|
95
|
+
// -------------------------------------------------------------------------
|
|
96
|
+
/**
|
|
97
|
+
* Spend quota and deliver.
|
|
98
|
+
*
|
|
99
|
+
* Refusal ORDER is deliberate: the permanent conditions are reported before
|
|
100
|
+
* the temporary one. Telling a child to "try again in 9s" when its quota is
|
|
101
|
+
* already spent sends it back for a second refusal it cannot act on.
|
|
102
|
+
*/
|
|
103
|
+
send(from, to, subjectRaw, bodyRaw) {
|
|
104
|
+
const target = to.trim().toLowerCase();
|
|
105
|
+
const cost = target === ALL_KEY ? TEAM_LIMITS.broadcastCost : 1;
|
|
106
|
+
const recipients = this.resolveRecipients(from, target);
|
|
107
|
+
if (recipients.length === 0) {
|
|
108
|
+
return { ok: false, reason: 'unknown_recipient', recipients: this.participants() };
|
|
109
|
+
}
|
|
110
|
+
const used = this.sentCount(from);
|
|
111
|
+
if (used + cost > TEAM_LIMITS.messagesPerAgent) {
|
|
112
|
+
return { ok: false, reason: 'agent_quota', used, max: TEAM_LIMITS.messagesPerAgent };
|
|
113
|
+
}
|
|
114
|
+
if (this.total + cost > TEAM_LIMITS.messagesPerDispatch) {
|
|
115
|
+
return {
|
|
116
|
+
ok: false,
|
|
117
|
+
reason: 'dispatch_quota',
|
|
118
|
+
used: this.total,
|
|
119
|
+
max: TEAM_LIMITS.messagesPerDispatch,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
const last = this.lastSentAt.get(from);
|
|
123
|
+
const now = this.now();
|
|
124
|
+
if (last !== undefined && now - last < TEAM_LIMITS.minSendIntervalMs) {
|
|
125
|
+
return {
|
|
126
|
+
ok: false,
|
|
127
|
+
reason: 'too_soon',
|
|
128
|
+
retryInSeconds: Math.max(1, Math.ceil((TEAM_LIMITS.minSendIntervalMs - (now - last)) / 1000)),
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
// Clamped, never rejected: an over-long subject is a formatting slip, and
|
|
132
|
+
// refusing the whole message for it would spend a turn to convey nothing.
|
|
133
|
+
const subject = subjectRaw.trim().slice(0, TEAM_LIMITS.subjectChars);
|
|
134
|
+
const body = bodyRaw.trim().slice(0, TEAM_LIMITS.bodyChars);
|
|
135
|
+
this.sentBy.set(from, used + cost);
|
|
136
|
+
this.lastSentAt.set(from, now);
|
|
137
|
+
this.total += cost;
|
|
138
|
+
for (const recipient of recipients) {
|
|
139
|
+
const message = { from, to: recipient, subject, body, at: now };
|
|
140
|
+
this.last = message;
|
|
141
|
+
this.onMessage?.(message);
|
|
142
|
+
if (recipient === LEAD_KEY) {
|
|
143
|
+
// The lead is blocked inside `task` for the whole dispatch (§1.3), so
|
|
144
|
+
// its mail is COLLECTED rather than delivered: it reaches the model in
|
|
145
|
+
// the report, and the panel shows it live. Injecting it into the lead's
|
|
146
|
+
// history mid-tool is what §1.3 rules out.
|
|
147
|
+
this.lead.push(message);
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
if (!this.deliverToWaiter(recipient, message)) {
|
|
151
|
+
this.mailboxes.get(recipient)?.push(message);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return {
|
|
155
|
+
ok: true,
|
|
156
|
+
delivered: recipients,
|
|
157
|
+
spent: cost,
|
|
158
|
+
remaining: Math.max(0, TEAM_LIMITS.messagesPerAgent - (used + cost)),
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
resolveRecipients(from, target) {
|
|
162
|
+
if (target === ALL_KEY)
|
|
163
|
+
return this.participants().filter((p) => p !== from);
|
|
164
|
+
if (target === LEAD_KEY)
|
|
165
|
+
return [LEAD_KEY];
|
|
166
|
+
const match = this.labels.find((l) => l.toLowerCase() === target);
|
|
167
|
+
if (!match || match === from)
|
|
168
|
+
return [];
|
|
169
|
+
return [match];
|
|
170
|
+
}
|
|
171
|
+
// -------------------------------------------------------------------------
|
|
172
|
+
// Receive
|
|
173
|
+
// -------------------------------------------------------------------------
|
|
174
|
+
waiterFor(label) {
|
|
175
|
+
for (const w of this.waiters)
|
|
176
|
+
if (w.key === label)
|
|
177
|
+
return w;
|
|
178
|
+
return undefined;
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Decide whether waiting could possibly pay off (F-3).
|
|
182
|
+
*
|
|
183
|
+
* TAKES `timeoutMs`, NOT `deadlineAt` (P1-1). The deadline is derived here,
|
|
184
|
+
* from the one clock this class has, so it is comparable with every registered
|
|
185
|
+
* `Waiter.deadlineAt`.
|
|
186
|
+
*
|
|
187
|
+
* PURE: no counters, no mutation, no waiter registration. `makeTeamWait`
|
|
188
|
+
* records the refusal, because a predicate with a counter inside it is a
|
|
189
|
+
* predicate people stop trusting (D-6).
|
|
190
|
+
*
|
|
191
|
+
* BIASED TOWARD PERMITTING (D-16). Every branch that cannot be decided returns
|
|
192
|
+
* `null`. A wait that should have been refused costs the child seconds it can
|
|
193
|
+
* see; a refusal that should have been a wait destroys a result the child
|
|
194
|
+
* cannot see was ever possible. The asymmetry is total, so the bias is total.
|
|
195
|
+
*/
|
|
196
|
+
checkWaitable(key, opts) {
|
|
197
|
+
const peers = this.peersOf(key);
|
|
198
|
+
if (peers.length === 0)
|
|
199
|
+
return { reason: 'no_peers' };
|
|
200
|
+
let acceptable = peers;
|
|
201
|
+
let want;
|
|
202
|
+
if (opts.from !== undefined) {
|
|
203
|
+
const canonical = this.resolveLabel(opts.from);
|
|
204
|
+
// Covers a typo, `lead` (which never sends - its mail is collected for the
|
|
205
|
+
// report and there is no `team_send` on a lead), and the caller's own
|
|
206
|
+
// label. All three are "this wait can never be satisfied", and none of
|
|
207
|
+
// them is "you have no teammates" (P1-2).
|
|
208
|
+
if (canonical === undefined || canonical === key) {
|
|
209
|
+
return { reason: 'unknown_sender', asked: opts.from.trim(), peers };
|
|
210
|
+
}
|
|
211
|
+
acceptable = [canonical];
|
|
212
|
+
want = canonical;
|
|
213
|
+
}
|
|
214
|
+
// Something is already in the mailbox: `wait()` will return it synchronously,
|
|
215
|
+
// so this is never a pointless wait however dead the peers are. Matched with
|
|
216
|
+
// the SAME canonical label `wait()` will use (P1-3).
|
|
217
|
+
const queued = this.mailboxes.get(key) ?? [];
|
|
218
|
+
if (queued.some((m) => want === undefined || m.from === want))
|
|
219
|
+
return null;
|
|
220
|
+
const deadlineAt = this.now() + Math.max(0, opts.timeoutMs);
|
|
221
|
+
const finished = (p) => !this.canSend(p);
|
|
222
|
+
const blockedPast = (p) => {
|
|
223
|
+
const w = this.waiterFor(p);
|
|
224
|
+
return w !== undefined && w.deadlineAt >= deadlineAt;
|
|
225
|
+
};
|
|
226
|
+
const stuck = [];
|
|
227
|
+
for (const peer of acceptable) {
|
|
228
|
+
if (finished(peer) || blockedPast(peer)) {
|
|
229
|
+
stuck.push(peer);
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
return null; // this one could still send in time
|
|
233
|
+
}
|
|
234
|
+
const cannotAct = new Set([key, ...peers.filter(finished), ...stuck]);
|
|
235
|
+
this.closeCannotAct(cannotAct, key, finished);
|
|
236
|
+
if (acceptable.some((p) => !cannotAct.has(p)))
|
|
237
|
+
return null;
|
|
238
|
+
const dead = acceptable.filter(finished);
|
|
239
|
+
if (dead.length === acceptable.length)
|
|
240
|
+
return { reason: 'all_finished', who: dead };
|
|
241
|
+
return { reason: 'all_blocked', who: acceptable };
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Shrink `cannotAct` to its fixpoint, in place (P0-3).
|
|
245
|
+
*
|
|
246
|
+
* Everyone acceptable being stuck is NOT yet a deadlock proof, because a
|
|
247
|
+
* blocked peer can be released EARLY by a third party: `a` waits on `b`, `b`
|
|
248
|
+
* waits on `c`, `c` messages `b` at t=10, `b` resumes and messages `a`. A
|
|
249
|
+
* deadline comparison alone refuses `a` at t=0 and throws that outcome away.
|
|
250
|
+
*
|
|
251
|
+
* So a blocked peer stays in the set only while everyone who could wake it is
|
|
252
|
+
* also in it (or is the caller, who is about to block). If growth stops with
|
|
253
|
+
* an acceptable peer outside the set, that peer can still be woken and the
|
|
254
|
+
* wait must be permitted.
|
|
255
|
+
*
|
|
256
|
+
* Terminates because each pass either removes at least one member or stops. At
|
|
257
|
+
* ten participants the worst case is a hundred set lookups, once per
|
|
258
|
+
* `team_wait`, and `team_wait` is itself rate-limited by the message budget.
|
|
259
|
+
*/
|
|
260
|
+
closeCannotAct(cannotAct, key, finished) {
|
|
261
|
+
for (;;) {
|
|
262
|
+
let shrank = false;
|
|
263
|
+
for (const p of [...cannotAct]) {
|
|
264
|
+
if (p === key || finished(p))
|
|
265
|
+
continue;
|
|
266
|
+
const w = this.waiterFor(p);
|
|
267
|
+
// Not blocked at all -> free to send whenever it likes.
|
|
268
|
+
if (w === undefined) {
|
|
269
|
+
cannotAct.delete(p);
|
|
270
|
+
shrank = true;
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
// Who could release `p`? Its own acceptable senders - and `peersOf`, NOT
|
|
274
|
+
// `participants`, because the lead is never a sender. Using
|
|
275
|
+
// `participants` here would put `lead` in every from-less waiter's waker
|
|
276
|
+
// set, `lead` is never in `cannotAct`, and `all_blocked` would become
|
|
277
|
+
// unreachable for exactly the from-less waits it was written for.
|
|
278
|
+
const wakers = w.from ? [w.from] : this.peersOf(p);
|
|
279
|
+
if (wakers.some((x) => x !== p && !cannotAct.has(x))) {
|
|
280
|
+
cannotAct.delete(p);
|
|
281
|
+
shrank = true;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
if (!shrank)
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
/** Take everything queued for `key`. Called by `withMailboxTail` (§3.6). */
|
|
289
|
+
drain(key) {
|
|
290
|
+
const box = this.mailboxes.get(key);
|
|
291
|
+
if (!box || box.length === 0)
|
|
292
|
+
return [];
|
|
293
|
+
return box.splice(0, box.length);
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* Block until a matching message arrives, the signal aborts, or the deadline
|
|
297
|
+
* elapses. Resolves `null` on both non-delivery paths — from the model's point
|
|
298
|
+
* of view an abandoned wait and an expired one want the same guidance.
|
|
299
|
+
*
|
|
300
|
+
* THE CALLER PAUSES THE CHILD'S IDLE WATCHDOG around this (see
|
|
301
|
+
* `comm-tools.ts`). A deliberate wait is not a wedged agent, and the child
|
|
302
|
+
* emits nothing while it is here.
|
|
303
|
+
*/
|
|
304
|
+
wait(key, opts) {
|
|
305
|
+
const queued = this.mailboxes.get(key) ?? [];
|
|
306
|
+
const index = queued.findIndex((m) => !opts.from || m.from === opts.from);
|
|
307
|
+
if (index >= 0)
|
|
308
|
+
return Promise.resolve(queued.splice(index, 1)[0] ?? null);
|
|
309
|
+
return new Promise((resolve) => {
|
|
310
|
+
let done = false;
|
|
311
|
+
const waiter = {
|
|
312
|
+
key,
|
|
313
|
+
...(opts.from ? { from: opts.from } : {}),
|
|
314
|
+
// On THIS bus's clock, so `checkWaitable` can compare deadlines (P1-1).
|
|
315
|
+
deadlineAt: this.now() + Math.max(0, opts.timeoutMs),
|
|
316
|
+
settle: (message) => {
|
|
317
|
+
if (done)
|
|
318
|
+
return;
|
|
319
|
+
done = true;
|
|
320
|
+
clearTimeout(timer);
|
|
321
|
+
opts.signal?.removeEventListener('abort', onAbort);
|
|
322
|
+
this.waiters.delete(waiter);
|
|
323
|
+
resolve(message);
|
|
324
|
+
},
|
|
325
|
+
};
|
|
326
|
+
const onAbort = () => waiter.settle(null);
|
|
327
|
+
const timer = setTimeout(() => waiter.settle(null), Math.max(0, opts.timeoutMs));
|
|
328
|
+
// `unref` keeps a pending wait from holding the process open on exit; it
|
|
329
|
+
// does not exist on the browser timer type, hence the guard.
|
|
330
|
+
timer.unref?.();
|
|
331
|
+
this.waiters.add(waiter);
|
|
332
|
+
if (opts.signal?.aborted) {
|
|
333
|
+
waiter.settle(null);
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
opts.signal?.addEventListener('abort', onAbort, { once: true });
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
/** Settle every outstanding wait with `null`. Called on abort and dispose. */
|
|
340
|
+
cancelAllWaits() {
|
|
341
|
+
for (const waiter of [...this.waiters])
|
|
342
|
+
waiter.settle(null);
|
|
343
|
+
}
|
|
344
|
+
deliverToWaiter(key, message) {
|
|
345
|
+
for (const waiter of this.waiters) {
|
|
346
|
+
if (waiter.key !== key)
|
|
347
|
+
continue;
|
|
348
|
+
if (waiter.from && waiter.from !== message.from)
|
|
349
|
+
continue;
|
|
350
|
+
waiter.settle(message);
|
|
351
|
+
return true;
|
|
352
|
+
}
|
|
353
|
+
return false;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
/** Render queued mail as the `<team_mail>` block appended to a tool result. */
|
|
357
|
+
export function renderMailBlock(messages) {
|
|
358
|
+
const lines = ['<team_mail>'];
|
|
359
|
+
for (const m of messages) {
|
|
360
|
+
lines.push(`from ${m.from}: ${m.subject}`);
|
|
361
|
+
if (m.body.length > 0)
|
|
362
|
+
lines.push(m.body);
|
|
363
|
+
}
|
|
364
|
+
lines.push('</team_mail>');
|
|
365
|
+
return lines.join('\n');
|
|
366
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `team_send` / `team_wait` and the `withMailboxTail` wrapper — the subagent
|
|
3
|
+
* half of the team bus (team-subagents §3.6 / §4.2).
|
|
4
|
+
*
|
|
5
|
+
* ASCII ONLY: `src/team/**` is inside the glyph scanner's scope.
|
|
6
|
+
*
|
|
7
|
+
* REGISTERED ON CHILDREN ONLY. Neither tool ever reaches a lead, which is why
|
|
8
|
+
* neither is in `HOST_TOOL_NAMES` (P2-5) and why both are in
|
|
9
|
+
* `TEAM_SUBAGENT_TOOL_NAMES` — the `policyExempt` set that keeps the lead's
|
|
10
|
+
* `allowed-tools` ceiling from refusing tools it has never heard of and
|
|
11
|
+
* dead-ending the channel (§3.11).
|
|
12
|
+
*
|
|
13
|
+
* EVERY RESULT IS A NON-ERROR `textResult`, refusals included (D-7). An
|
|
14
|
+
* `errorResult` reads as a malfunction and invites an immediate retry, which is
|
|
15
|
+
* exactly the behaviour the rate limits exist to prevent.
|
|
16
|
+
*/
|
|
17
|
+
import { type AgentTool } from '@aragon-agent/core';
|
|
18
|
+
import { TeamBus } from './bus.js';
|
|
19
|
+
import type { WatchdogPausable } from './human-queue.js';
|
|
20
|
+
/**
|
|
21
|
+
* `team_send`.
|
|
22
|
+
*
|
|
23
|
+
* The bus owns quota, interval and delivery; this tool owns only the wording,
|
|
24
|
+
* because the wording is what the model acts on and it must name the limit that
|
|
25
|
+
* was hit and what to do instead.
|
|
26
|
+
*/
|
|
27
|
+
export declare function makeTeamSend(bus: TeamBus, selfKey: string): AgentTool;
|
|
28
|
+
export interface TeamWaitDeps {
|
|
29
|
+
/**
|
|
30
|
+
* The child's own `Agent`, read LAZILY.
|
|
31
|
+
*
|
|
32
|
+
* The tool array has to exist before `new Agent(...)` can be constructed, so
|
|
33
|
+
* there is a window in which this returns `null`. Resolving it eagerly would
|
|
34
|
+
* capture that `null` for the life of the child and silently drop the
|
|
35
|
+
* watchdog pause — the failure would only show up as a mysterious
|
|
36
|
+
* `idle watchdog fired` during a legitimate wait.
|
|
37
|
+
*/
|
|
38
|
+
getAgent: () => WatchdogPausable | null;
|
|
39
|
+
/** Ceiling on any single wait: never longer than the child's own budget. */
|
|
40
|
+
maxWaitMs: number;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* `team_wait`.
|
|
44
|
+
*
|
|
45
|
+
* Runs with THAT CHILD'S idle watchdog paused, in a `try/finally` — the same
|
|
46
|
+
* treatment `withHumanWait` gives an overlay and for the same reason: a
|
|
47
|
+
* deliberate wait is not a wedged tool, and the child emits nothing while it is
|
|
48
|
+
* blocked here (I-3, one level down).
|
|
49
|
+
*/
|
|
50
|
+
export declare function makeTeamWait(bus: TeamBus, selfKey: string, deps: TeamWaitDeps): AgentTool;
|
|
51
|
+
/**
|
|
52
|
+
* Append anything queued for `key` to the tool's result, inside a `<team_mail>`
|
|
53
|
+
* block.
|
|
54
|
+
*
|
|
55
|
+
* Same `{...tool, execute}` shape as `withConfirmation` / `withToolPolicy` /
|
|
56
|
+
* `withPlanModeGate`, so nothing in the engine needs to know this happened. The
|
|
57
|
+
* mailbox is drained EVEN WHEN the wrapped tool errored: a refused `bash` call
|
|
58
|
+
* is still a delivery opportunity, and holding mail back until a successful call
|
|
59
|
+
* would make delivery depend on whether the child happened to be doing well.
|
|
60
|
+
*/
|
|
61
|
+
export declare function withMailboxTail(tool: AgentTool, bus: TeamBus, key: string): AgentTool;
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `team_send` / `team_wait` and the `withMailboxTail` wrapper — the subagent
|
|
3
|
+
* half of the team bus (team-subagents §3.6 / §4.2).
|
|
4
|
+
*
|
|
5
|
+
* ASCII ONLY: `src/team/**` is inside the glyph scanner's scope.
|
|
6
|
+
*
|
|
7
|
+
* REGISTERED ON CHILDREN ONLY. Neither tool ever reaches a lead, which is why
|
|
8
|
+
* neither is in `HOST_TOOL_NAMES` (P2-5) and why both are in
|
|
9
|
+
* `TEAM_SUBAGENT_TOOL_NAMES` — the `policyExempt` set that keeps the lead's
|
|
10
|
+
* `allowed-tools` ceiling from refusing tools it has never heard of and
|
|
11
|
+
* dead-ending the channel (§3.11).
|
|
12
|
+
*
|
|
13
|
+
* EVERY RESULT IS A NON-ERROR `textResult`, refusals included (D-7). An
|
|
14
|
+
* `errorResult` reads as a malfunction and invites an immediate retry, which is
|
|
15
|
+
* exactly the behaviour the rate limits exist to prevent.
|
|
16
|
+
*/
|
|
17
|
+
import { textResult } from '@aragon-agent/core';
|
|
18
|
+
import { TEAM_LIMITS } from './limits.js';
|
|
19
|
+
import { renderMailBlock } from './bus.js';
|
|
20
|
+
const SEND_DESCRIPTION = 'Message a teammate. Use it only when it changes what they should do - a finding they ' +
|
|
21
|
+
'would otherwise duplicate, or a file you are about to change. You get ' +
|
|
22
|
+
`${TEAM_LIMITS.messagesPerAgent} messages for this whole task and at most one every ` +
|
|
23
|
+
`${Math.round(TEAM_LIMITS.minSendIntervalMs / 1000)} seconds; spending them on status updates means you cannot send ` +
|
|
24
|
+
'the one that matters. Incoming messages are appended to your next tool result ' +
|
|
25
|
+
'automatically; you do not need to poll.';
|
|
26
|
+
const WAIT_DESCRIPTION = 'Block until a teammate messages you. Use this only when you genuinely cannot continue ' +
|
|
27
|
+
'without an answer - it spends wall-clock time and your teammate may never reply. ' +
|
|
28
|
+
'Messages you are simply expecting arrive on your next tool result without waiting.';
|
|
29
|
+
/**
|
|
30
|
+
* `team_send`.
|
|
31
|
+
*
|
|
32
|
+
* The bus owns quota, interval and delivery; this tool owns only the wording,
|
|
33
|
+
* because the wording is what the model acts on and it must name the limit that
|
|
34
|
+
* was hit and what to do instead.
|
|
35
|
+
*/
|
|
36
|
+
export function makeTeamSend(bus, selfKey) {
|
|
37
|
+
return {
|
|
38
|
+
name: 'team_send',
|
|
39
|
+
label: 'Message teammate',
|
|
40
|
+
description: SEND_DESCRIPTION,
|
|
41
|
+
parameters: {
|
|
42
|
+
type: 'object',
|
|
43
|
+
properties: {
|
|
44
|
+
to: { type: 'string', description: 'A peer label, "lead", or "all".' },
|
|
45
|
+
subject: { type: 'string', description: `<= ${TEAM_LIMITS.subjectChars} chars.` },
|
|
46
|
+
body: { type: 'string', description: `<= ${TEAM_LIMITS.bodyChars} chars.` },
|
|
47
|
+
},
|
|
48
|
+
required: ['to', 'subject', 'body'],
|
|
49
|
+
},
|
|
50
|
+
async execute(_id, params) {
|
|
51
|
+
const p = params;
|
|
52
|
+
const to = typeof p.to === 'string' ? p.to : '';
|
|
53
|
+
const subject = typeof p.subject === 'string' ? p.subject : '';
|
|
54
|
+
const body = typeof p.body === 'string' ? p.body : '';
|
|
55
|
+
if (to.trim().length === 0 || subject.trim().length === 0) {
|
|
56
|
+
return textResult('Not sent: team_send needs a "to" and a "subject".');
|
|
57
|
+
}
|
|
58
|
+
const verdict = bus.send(selfKey, to, subject, body);
|
|
59
|
+
if (verdict.ok) {
|
|
60
|
+
return textResult(`Delivered to ${verdict.delivered.join(', ')}. ${verdict.remaining} of ` +
|
|
61
|
+
`${TEAM_LIMITS.messagesPerAgent} messages left.`);
|
|
62
|
+
}
|
|
63
|
+
switch (verdict.reason) {
|
|
64
|
+
case 'unknown_recipient':
|
|
65
|
+
return textResult(`Not sent: no subagent labelled "${to.trim()}". Teammates: ` +
|
|
66
|
+
`${verdict.recipients.filter((r) => r !== selfKey).join(', ')}.`);
|
|
67
|
+
case 'agent_quota':
|
|
68
|
+
return textResult(`Not sent: message quota spent (${verdict.used}/${verdict.max}). Finish your work ` +
|
|
69
|
+
'and put it in your final summary.');
|
|
70
|
+
case 'dispatch_quota':
|
|
71
|
+
return textResult(`Not sent: the team has spent its message budget (${verdict.used}/${verdict.max}). ` +
|
|
72
|
+
'Continue on your own and report what you found.');
|
|
73
|
+
case 'too_soon':
|
|
74
|
+
return textResult(`Not sent: one message every ${Math.round(TEAM_LIMITS.minSendIntervalMs / 1000)}s. Try again in ` +
|
|
75
|
+
`${verdict.retryInSeconds}s, or continue and report instead.`);
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Label lists for a refusal sentence: joined with `and`, capped at three names.
|
|
82
|
+
*
|
|
83
|
+
* A 10-way dispatch must not produce a 200-character refusal - the child has to
|
|
84
|
+
* read it, and the point of the sentence is what to do next, not a roll call.
|
|
85
|
+
*/
|
|
86
|
+
function nameList(names) {
|
|
87
|
+
if (names.length === 0)
|
|
88
|
+
return 'nobody';
|
|
89
|
+
if (names.length === 1)
|
|
90
|
+
return names[0];
|
|
91
|
+
if (names.length <= 3)
|
|
92
|
+
return `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`;
|
|
93
|
+
return `${names.slice(0, 3).join(', ')} and ${names.length - 3} others`;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* What the child is told instead of blocking (F-3).
|
|
97
|
+
*
|
|
98
|
+
* Following the refusal style `makeTeamSend` already uses: name the reason and
|
|
99
|
+
* say what to do instead, never as an `errorResult` (D-7 / D-11).
|
|
100
|
+
*
|
|
101
|
+
* `unknown_sender` is its own reason rather than folded into `no_peers` (P1-2)
|
|
102
|
+
* because it is the only one of the four the child can act on IN THIS TURN. The
|
|
103
|
+
* other three say "give up on the message"; this one says "you asked for a name
|
|
104
|
+
* nobody has, here are the names" - and telling a child in a five-way fan-out
|
|
105
|
+
* that it is "the only subagent in this dispatch" is simply false.
|
|
106
|
+
*/
|
|
107
|
+
function refusalText(refusal) {
|
|
108
|
+
switch (refusal.reason) {
|
|
109
|
+
case 'no_peers':
|
|
110
|
+
return ('Nobody to wait for: you are the only subagent in this dispatch. Continue and put ' +
|
|
111
|
+
'what you found in your final summary.');
|
|
112
|
+
case 'unknown_sender':
|
|
113
|
+
return (`Nobody here is called "${refusal.asked}". Teammates: ${nameList(refusal.peers)}. ` +
|
|
114
|
+
'(The lead never sends messages.) Continue, or wait again naming one of those.');
|
|
115
|
+
case 'all_finished':
|
|
116
|
+
return (`Nobody can answer: ${nameList(refusal.who)} already finished. Continue without it ` +
|
|
117
|
+
'and say so in your summary.');
|
|
118
|
+
case 'all_blocked':
|
|
119
|
+
return (`Deadlock avoided: ${nameList(refusal.who)} is waiting for a message too, and cannot ` +
|
|
120
|
+
'be released before your own deadline. Continue with what you have and report it.');
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* `team_wait`.
|
|
125
|
+
*
|
|
126
|
+
* Runs with THAT CHILD'S idle watchdog paused, in a `try/finally` — the same
|
|
127
|
+
* treatment `withHumanWait` gives an overlay and for the same reason: a
|
|
128
|
+
* deliberate wait is not a wedged tool, and the child emits nothing while it is
|
|
129
|
+
* blocked here (I-3, one level down).
|
|
130
|
+
*/
|
|
131
|
+
export function makeTeamWait(bus, selfKey, deps) {
|
|
132
|
+
return {
|
|
133
|
+
name: 'team_wait',
|
|
134
|
+
label: 'Wait for teammate',
|
|
135
|
+
description: WAIT_DESCRIPTION,
|
|
136
|
+
parameters: {
|
|
137
|
+
type: 'object',
|
|
138
|
+
properties: {
|
|
139
|
+
from: {
|
|
140
|
+
type: 'string',
|
|
141
|
+
description: 'Only accept a message from this label. Omit for any.',
|
|
142
|
+
},
|
|
143
|
+
timeoutSeconds: {
|
|
144
|
+
type: 'number',
|
|
145
|
+
default: TEAM_LIMITS.waitDefaultSeconds,
|
|
146
|
+
description: `${TEAM_LIMITS.waitMinSeconds}-${TEAM_LIMITS.waitMaxSeconds}.`,
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
async execute(_id, params, ctx) {
|
|
151
|
+
const p = params;
|
|
152
|
+
const from = typeof p.from === 'string' && p.from.trim().length > 0 ? p.from.trim() : undefined;
|
|
153
|
+
const requested = typeof p.timeoutSeconds === 'number' && Number.isFinite(p.timeoutSeconds)
|
|
154
|
+
? p.timeoutSeconds
|
|
155
|
+
: TEAM_LIMITS.waitDefaultSeconds;
|
|
156
|
+
const seconds = Math.min(TEAM_LIMITS.waitMaxSeconds, Math.max(TEAM_LIMITS.waitMinSeconds, Math.round(requested)));
|
|
157
|
+
const timeoutMs = Math.min(seconds * 1000, deps.maxWaitMs);
|
|
158
|
+
// `timeoutMs`, not a deadline: the bus owns the clock (P1-1). Checked
|
|
159
|
+
// BEFORE the watchdog pause on purpose - the refusal path returns
|
|
160
|
+
// synchronously, and a pause/resume pair around a synchronous return is a
|
|
161
|
+
// needless state transition on the one object whose state is hardest to
|
|
162
|
+
// reason about.
|
|
163
|
+
const refusal = bus.checkWaitable(selfKey, {
|
|
164
|
+
...(from ? { from } : {}),
|
|
165
|
+
timeoutMs,
|
|
166
|
+
});
|
|
167
|
+
if (refusal) {
|
|
168
|
+
bus.recordBlockedWait(selfKey);
|
|
169
|
+
return textResult(refusalText(refusal));
|
|
170
|
+
}
|
|
171
|
+
// Past the gate, `from` is known to name a real peer, so hand `wait()` the
|
|
172
|
+
// CANONICAL label rather than what the model typed (P1-3). `wait()` and
|
|
173
|
+
// `deliverToWaiter` compare exactly; this is the one line that makes them
|
|
174
|
+
// agree with the gate that just permitted the call.
|
|
175
|
+
const fromLabel = from ? bus.resolveLabel(from) : undefined;
|
|
176
|
+
const agent = deps.getAgent();
|
|
177
|
+
agent?.pauseIdleWatchdog();
|
|
178
|
+
let message;
|
|
179
|
+
try {
|
|
180
|
+
message = await bus.wait(selfKey, {
|
|
181
|
+
...(fromLabel ? { from: fromLabel } : {}),
|
|
182
|
+
timeoutMs,
|
|
183
|
+
...(ctx.signal ? { signal: ctx.signal } : {}),
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
finally {
|
|
187
|
+
agent?.resumeIdleWatchdog();
|
|
188
|
+
}
|
|
189
|
+
if (!message) {
|
|
190
|
+
return textResult(`No message arrived within ${Math.round(timeoutMs / 1000)}s. Continue without it.`);
|
|
191
|
+
}
|
|
192
|
+
return textResult(renderMailBlock([message]));
|
|
193
|
+
},
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Append anything queued for `key` to the tool's result, inside a `<team_mail>`
|
|
198
|
+
* block.
|
|
199
|
+
*
|
|
200
|
+
* Same `{...tool, execute}` shape as `withConfirmation` / `withToolPolicy` /
|
|
201
|
+
* `withPlanModeGate`, so nothing in the engine needs to know this happened. The
|
|
202
|
+
* mailbox is drained EVEN WHEN the wrapped tool errored: a refused `bash` call
|
|
203
|
+
* is still a delivery opportunity, and holding mail back until a successful call
|
|
204
|
+
* would make delivery depend on whether the child happened to be doing well.
|
|
205
|
+
*/
|
|
206
|
+
export function withMailboxTail(tool, bus, key) {
|
|
207
|
+
const original = tool.execute;
|
|
208
|
+
return {
|
|
209
|
+
...tool,
|
|
210
|
+
async execute(id, params, ctx) {
|
|
211
|
+
const result = await original(id, params, ctx);
|
|
212
|
+
const mail = bus.drain(key);
|
|
213
|
+
if (mail.length === 0)
|
|
214
|
+
return result;
|
|
215
|
+
return {
|
|
216
|
+
...result,
|
|
217
|
+
content: [...result.content, { type: 'text', text: renderMailBlock(mail) }],
|
|
218
|
+
};
|
|
219
|
+
},
|
|
220
|
+
};
|
|
221
|
+
}
|