crewforth 3.1.0-rc.4 → 3.1.0-rc.6
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 +59 -0
- package/README.md +1 -1
- package/kit/AGENT_TEMPLATE.md +48 -19
- package/kit/CLAUDE.md +13 -0
- package/kit/agents/crew-backend-expert.md +8 -0
- package/kit/agents/crew-commit-agent.md +8 -0
- package/kit/agents/crew-database-expert.md +8 -0
- package/kit/agents/crew-devops-expert.md +8 -0
- package/kit/agents/crew-frontend-expert.md +8 -0
- package/kit/agents/crew-performance-expert.md +8 -0
- package/kit/agents/crew-planner.md +14 -0
- package/kit/agents/crew-privacy-agent.md +10 -2
- package/kit/agents/crew-review-agent.md +8 -0
- package/kit/agents/crew-security-expert.md +11 -4
- package/kit/agents/crew-session-manager.md +10 -3
- package/kit/agents/crew-test-expert.md +8 -0
- package/kit/eval/doctor.sh +16 -0
- package/kit/eval/smoke-test.sh +898 -11
- package/kit/hooks/agent-outcome.sh +416 -0
- package/kit/hooks/guard-agent-model.sh +819 -0
- package/kit/hooks/guard-bash.sh +26 -1
- package/kit/hooks/guard-schedule.sh +7 -2
- package/kit/hooks/guard-write.sh +31 -0
- package/kit/hooks/prompt-approval.sh +55 -5
- package/kit/settings.json +54 -0
- package/kit/skills/crew-loosen/SKILL.md +28 -0
- package/kit/studio/server/hooks/studio-gate.sh +90 -4
- package/kit/studio/server/hooks/studio-host.mjs +92 -0
- package/kit/studio/server/index.js +86 -7
- package/kit/studio/server/lib/agent-origin.js +44 -0
- package/kit/studio/server/lib/graph.js +142 -10
- package/kit/studio/server/lib/model-outcomes.js +287 -0
- package/kit/studio/server/lib/palette.js +5 -18
- package/kit/studio/server/lib/permissions.js +260 -17
- package/kit/studio/server/lib/session.js +41 -1
- package/kit/studio/server/lib/usage.js +103 -25
- package/kit/studio/web/app.js +251 -37
- package/kit/studio/web/approvals.js +53 -2
- package/kit/studio/web/ask.js +137 -0
- package/kit/studio/web/canvas.js +220 -59
- package/kit/studio/web/chat.js +19 -2
- package/kit/studio/web/convo.js +14 -1
- package/kit/studio/web/dock.js +30 -9
- package/kit/studio/web/graph-plan.js +66 -18
- package/kit/studio/web/icons/builtin.svg +1 -1
- package/kit/studio/web/index.html +11 -4
- package/kit/studio/web/inspect.js +33 -1
- package/kit/studio/web/keys.js +29 -0
- package/kit/studio/web/list-plan.js +8 -3
- package/kit/studio/web/list.js +35 -7
- package/kit/studio/web/models-plan.js +167 -0
- package/kit/studio/web/models.js +127 -0
- package/kit/studio/web/names.js +84 -0
- package/kit/studio/web/style.css +134 -15
- package/kit/studio/web/timeline-plan.js +8 -0
- package/kit/studio/web/timeline.js +36 -10
- package/kit/studio/web/usage.js +53 -12
- package/package.json +1 -1
|
@@ -17,18 +17,20 @@ import os from 'node:os';
|
|
|
17
17
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
18
18
|
|
|
19
19
|
import { getFleet, measureSpawnCost } from './lib/fleet.js';
|
|
20
|
-
import { projectDir, listSessions, findSession, listProjects, sessionCwd } from './lib/projects.js';
|
|
20
|
+
import { projectDir, listSessions, findSession, listProjects, sessionCwd, agentMetaFiles } from './lib/projects.js';
|
|
21
|
+
import { agentOrigins } from './lib/agent-origin.js';
|
|
21
22
|
import { buildGraph, agentDetail, conversation, STALE_MS } from './lib/graph.js';
|
|
22
23
|
import { cachedUsage } from './lib/usage.js';
|
|
24
|
+
import { readModels, readRecord, readCosts } from './lib/model-outcomes.js';
|
|
23
25
|
|
|
24
26
|
const USAGE_BATCH = 12;
|
|
25
27
|
import { palette } from './lib/palette.js';
|
|
26
28
|
import { latestVersion, latestVersionCached, kitStatus } from './lib/kit.js';
|
|
27
29
|
import { parsePeers, askAll, ask } from './lib/peers.js';
|
|
28
30
|
import {
|
|
29
|
-
createSession, getSession, listSessionsOwned, reap, stopAll, ALLOWED_MODES,
|
|
31
|
+
createSession, getSession, listSessionsOwned, reap, stopAll, dropSpools, ALLOWED_MODES,
|
|
30
32
|
} from './lib/session.js';
|
|
31
|
-
import { decide, pending, alwaysList, revoke } from './lib/permissions.js';
|
|
33
|
+
import { decide, pending, alwaysList, revoke, sweepLeft } from './lib/permissions.js';
|
|
32
34
|
import { open as openTerminal, plan as terminalPlan } from './lib/terminal.js';
|
|
33
35
|
import { gateLog, gateReport, sessionStats, board } from './lib/kit-telemetry.js';
|
|
34
36
|
import { writeState, clearStateSync, findRunning } from './lib/instance.js';
|
|
@@ -138,8 +140,45 @@ export function writeAllowed(req) {
|
|
|
138
140
|
return { ok: true };
|
|
139
141
|
}
|
|
140
142
|
|
|
143
|
+
/**
|
|
144
|
+
* The record of outcomes for a project, with each agent's cost where it can be read. Only the sessions written
|
|
145
|
+
* since the record began are opened, and each is read once and kept until it grows (usage.js).
|
|
146
|
+
*/
|
|
147
|
+
/** The ids of the agents of one session, from the files it left: the only lines of the record a page of it needs. */
|
|
148
|
+
async function agentIdsOf(session) {
|
|
149
|
+
if (!session?.subagentsDir) return [];
|
|
150
|
+
return (await agentMetaFiles(session.subagentsDir)).map((m) => path.basename(m).replace(/^agent-/, '').replace(/\.meta\.json$/, ''));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* What the classes of a project cost: each agent's tokens at list price, from the transcripts of the project's
|
|
155
|
+
* sessions that are on this machine. Reading those is the expensive part of the Models view, so it is asked for
|
|
156
|
+
* apart from the record and less often, and a session whose transcript has not changed is not read again
|
|
157
|
+
* (`cachedUsage`).
|
|
158
|
+
*/
|
|
159
|
+
async function modelCosts(cwd) {
|
|
160
|
+
const rec = readRecord(cwd);
|
|
161
|
+
if (!rec.measured) return { measured: false, reason: rec.reason, costs: {}, sessionsRead: 0 };
|
|
162
|
+
const per = new Map();
|
|
163
|
+
const dir = await projectDir(cwd);
|
|
164
|
+
let read = 0;
|
|
165
|
+
if (dir) {
|
|
166
|
+
const since = (rec.firstAt ?? 0) - 3600_000;
|
|
167
|
+
for (const s of (await listSessions(dir)).filter((x) => x.modifiedAt >= since).slice(0, MODELS_SESSIONS_MAX)) {
|
|
168
|
+
const u = await cachedUsage(s);
|
|
169
|
+
read += 1;
|
|
170
|
+
for (const [id, a] of Object.entries(u.agents ?? {})) per.set(id, a.cost);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return { measured: true, costs: readCosts(cwd, (id) => (per.has(id) ? per.get(id) : null)), sessionsRead: read };
|
|
174
|
+
}
|
|
175
|
+
const MODELS_SESSIONS_MAX = 60;
|
|
176
|
+
|
|
141
177
|
const MAX_BODY_BYTES = 1024 * 1024;
|
|
142
178
|
|
|
179
|
+
/** The session events after which a subscriber is sent the session's summary again. */
|
|
180
|
+
export const STATE_AFTER = new Set(['result', 'exit', 'mode']);
|
|
181
|
+
|
|
143
182
|
function readBody(req) {
|
|
144
183
|
return new Promise((resolve) => {
|
|
145
184
|
let size = 0;
|
|
@@ -205,7 +244,7 @@ async function handle(req, res) {
|
|
|
205
244
|
const session = await findSession(id);
|
|
206
245
|
if (!session) continue; // a session on another machine: not read here, and not guessed
|
|
207
246
|
const u = await cachedUsage(session);
|
|
208
|
-
usage[id] = { durationMs: u.durationMs, workedMs: u.workedMs, tokens: u.tokens.
|
|
247
|
+
usage[id] = { durationMs: u.durationMs, workedMs: u.workedMs, startedAt: u.startedAt, tokens: u.tokens.fresh, cost: u.cost, unpriced: u.unpriced };
|
|
209
248
|
}
|
|
210
249
|
return sendJson(res, 200, { measured: true, usage });
|
|
211
250
|
}
|
|
@@ -294,6 +333,24 @@ async function handle(req, res) {
|
|
|
294
333
|
}
|
|
295
334
|
|
|
296
335
|
// What Crewforth already measures about itself, reported as Crewforth reports it.
|
|
336
|
+
// Which model did which class of work in this project, and how it went: Crewforth's own record of outcomes,
|
|
337
|
+
// and the lines of it that belong to the session on screen. What the classes cost is the next route.
|
|
338
|
+
if (url.pathname === '/api/models') {
|
|
339
|
+
const sid = url.searchParams.get('session');
|
|
340
|
+
const session = sid ? await findSession(sid) : null;
|
|
341
|
+
const cwd = url.searchParams.get('cwd') || (session ? await sessionCwd(session.file, session.bytes) : null);
|
|
342
|
+
if (!cwd) return sendJson(res, 200, { measured: false, reason: 'no project named', classes: [], suggestions: [], byAgent: {} });
|
|
343
|
+
return sendJson(res, 200, readModels(cwd, { agents: await agentIdsOf(session) }));
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
if (url.pathname === '/api/models/cost') {
|
|
347
|
+
const sid = url.searchParams.get('session');
|
|
348
|
+
const session = sid ? await findSession(sid) : null;
|
|
349
|
+
const cwd = url.searchParams.get('cwd') || (session ? await sessionCwd(session.file, session.bytes) : null);
|
|
350
|
+
if (!cwd) return sendJson(res, 200, { measured: false, reason: 'no project named', costs: {}, sessionsRead: 0 });
|
|
351
|
+
return sendJson(res, 200, await modelCosts(cwd));
|
|
352
|
+
}
|
|
353
|
+
|
|
297
354
|
if (url.pathname === '/api/kit') {
|
|
298
355
|
const cwd = url.searchParams.get('cwd') || process.cwd();
|
|
299
356
|
const sid = url.searchParams.get('session');
|
|
@@ -310,6 +367,14 @@ async function handle(req, res) {
|
|
|
310
367
|
return sendJson(res, 200, palette());
|
|
311
368
|
}
|
|
312
369
|
|
|
370
|
+
// Where the agents of a session's project are defined: the project's own files and the user's.
|
|
371
|
+
if (url.pathname === '/api/agents') {
|
|
372
|
+
const sid = url.searchParams.get('session');
|
|
373
|
+
const session = sid ? await findSession(sid) : null;
|
|
374
|
+
const cwd = session ? await sessionCwd(session.file, session.bytes) : null;
|
|
375
|
+
return sendJson(res, 200, { measured: true, ...agentOrigins(cwd) });
|
|
376
|
+
}
|
|
377
|
+
|
|
313
378
|
if (url.pathname === '/api/sessions') {
|
|
314
379
|
const cwd = url.searchParams.get('cwd') || process.cwd();
|
|
315
380
|
const dir = await projectDir(cwd);
|
|
@@ -455,12 +520,12 @@ async function handle(req, res) {
|
|
|
455
520
|
const body = await readBody(req);
|
|
456
521
|
if (!body) return sendJson(res, 400, { ok: false, reason: 'body was not JSON' });
|
|
457
522
|
const toolUseId = decodeURIComponent(permMatch[2]);
|
|
458
|
-
const out = decide(s.id, toolUseId, body.verdict);
|
|
523
|
+
const out = decide(s.id, toolUseId, body.verdict, { answers: body.answers, mode: body.mode });
|
|
459
524
|
if (out.ok) s.noteDecision(toolUseId, body.verdict);
|
|
460
525
|
return sendJson(res, out.ok ? 200 : 400, out);
|
|
461
526
|
}
|
|
462
527
|
|
|
463
|
-
const ownedMatch = url.pathname.match(/^\/api\/owned\/([^/]+)(?:\/(message|stop|events))?$/);
|
|
528
|
+
const ownedMatch = url.pathname.match(/^\/api\/owned\/([^/]+)(?:\/(message|stop|events|mode))?$/);
|
|
464
529
|
if (ownedMatch) {
|
|
465
530
|
const s = getSession(decodeURIComponent(ownedMatch[1]));
|
|
466
531
|
if (!s) return sendJson(res, 404, { ok: false, reason: 'no such owned session' });
|
|
@@ -476,6 +541,13 @@ async function handle(req, res) {
|
|
|
476
541
|
|
|
477
542
|
if (verb === 'stop') return sendJson(res, 200, { ...s.stop(), session: s.summary() });
|
|
478
543
|
|
|
544
|
+
// The viewer chose another mode for the session, from the three a session may be started in.
|
|
545
|
+
if (verb === 'mode') {
|
|
546
|
+
const asked = await readBody(req);
|
|
547
|
+
const out = s.requestMode(asked?.mode);
|
|
548
|
+
return sendJson(res, out.ok ? 200 : 400, { ...out, session: s.summary() });
|
|
549
|
+
}
|
|
550
|
+
|
|
479
551
|
const body = await readBody(req);
|
|
480
552
|
if (!body) return sendJson(res, 400, { ok: false, reason: 'body was not JSON' });
|
|
481
553
|
const sent = s.send(body.text);
|
|
@@ -728,7 +800,9 @@ function ownedStream(req, res, url, session) {
|
|
|
728
800
|
|
|
729
801
|
const unsubscribe = session.subscribe((ev) => {
|
|
730
802
|
write('event', ev);
|
|
731
|
-
|
|
803
|
+
// The summary is what a pane paints its strip from. It follows what changes it: a turn ending, the session
|
|
804
|
+
// going, and the mode Claude Code reports. Without the last, the strip waited for the beat below.
|
|
805
|
+
if (STATE_AFTER.has(ev.rec?.type)) write('state', session.summary());
|
|
732
806
|
}, after);
|
|
733
807
|
|
|
734
808
|
const beat = setInterval(() => write('state', session.summary()), 15000);
|
|
@@ -815,6 +889,10 @@ async function main() {
|
|
|
815
889
|
process.exit(await selftest());
|
|
816
890
|
}
|
|
817
891
|
|
|
892
|
+
// What an earlier server left under the temp directory when it was killed: gone before this one makes its own.
|
|
893
|
+
// Only a server that is about to run does this; asking for --help or --selftest removes nothing.
|
|
894
|
+
sweepLeft();
|
|
895
|
+
|
|
818
896
|
const server = http.createServer((req, res) => {
|
|
819
897
|
handle(req, res).catch((e) => {
|
|
820
898
|
process.stderr.write(`crewforth-studio: unhandled: ${e?.stack ?? e}\n`);
|
|
@@ -900,6 +978,7 @@ async function main() {
|
|
|
900
978
|
// Children outlive their parent unless told otherwise, and a panel that
|
|
901
979
|
// leaks running sessions is worse than one that never started them.
|
|
902
980
|
stopAll();
|
|
981
|
+
dropSpools();
|
|
903
982
|
server.close(() => process.exit(0));
|
|
904
983
|
setTimeout(() => process.exit(0), 2000).unref();
|
|
905
984
|
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Where a Claude Code agent is defined, as far as that can be read from disk.
|
|
2
|
+
//
|
|
3
|
+
// Claude Code takes an agent that is not built in from a Markdown file whose frontmatter names it: in the project
|
|
4
|
+
// (`.claude/agents/`), in the user's own directory (`~/.claude/agents/`), or in a plugin. This file reads the first
|
|
5
|
+
// two and nothing else. It keeps no list of built-in types: an agent found in neither place is reported as not
|
|
6
|
+
// found, which is all that is known about it.
|
|
7
|
+
//
|
|
8
|
+
// Not read: a plugin's files (a plugin agent is told by its name, `plugin:agent`, in web/names.js), agents given
|
|
9
|
+
// on the command line, managed settings, and files in a directory under `agents/`.
|
|
10
|
+
|
|
11
|
+
import fs from 'node:fs';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import { PROJECTS_ROOT } from './projects.js';
|
|
14
|
+
|
|
15
|
+
/** The agents one directory defines: { name: file name }. Null when the directory is not there or cannot be read. */
|
|
16
|
+
export function agentsIn(dir) {
|
|
17
|
+
let files;
|
|
18
|
+
try { files = fs.readdirSync(dir); } catch { return null; }
|
|
19
|
+
const out = {};
|
|
20
|
+
for (const f of files.sort()) {
|
|
21
|
+
if (!f.endsWith('.md')) continue;
|
|
22
|
+
let src;
|
|
23
|
+
try { src = fs.readFileSync(path.join(dir, f), 'utf8'); } catch { continue; }
|
|
24
|
+
// Frontmatter only, as the palette reads it: a "name:" in the body is not the agent's name.
|
|
25
|
+
const fm = src.replace(/\r\n/g, '\n').split(/^---$/m)[1] ?? '';
|
|
26
|
+
const name = fm.match(/^name:\s*(.+)$/m)?.[1]?.trim().replace(/^["']|["']$/g, '');
|
|
27
|
+
if (name && out[name] === undefined) out[name] = f;
|
|
28
|
+
}
|
|
29
|
+
return out;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Claude Code's own directory is the parent of its projects directory; the user's agents sit beside the projects. */
|
|
33
|
+
export const USER_AGENTS_DIR = path.join(path.dirname(PROJECTS_ROOT), 'agents');
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @returns { project, user } — each { name: file name }, or null where that directory was not read. A project's
|
|
37
|
+
* definition is the one Claude Code uses when both name the same agent, and it is listed in both here.
|
|
38
|
+
*/
|
|
39
|
+
export function agentOrigins(cwd, userDir = USER_AGENTS_DIR) {
|
|
40
|
+
return {
|
|
41
|
+
project: cwd ? agentsIn(path.join(cwd, '.claude', 'agents')) : null,
|
|
42
|
+
user: agentsIn(userDir),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
@@ -19,7 +19,7 @@ import path from 'node:path';
|
|
|
19
19
|
|
|
20
20
|
import { readAll, contextFill } from './transcript.js';
|
|
21
21
|
import { agentMetaFiles } from './projects.js';
|
|
22
|
-
import { Usage,
|
|
22
|
+
import { Usage, withTimes } from './usage.js';
|
|
23
23
|
import { detailOf } from './permissions.js';
|
|
24
24
|
|
|
25
25
|
const SESSION_NODE = 'session';
|
|
@@ -60,6 +60,109 @@ function harvestCompletions(text, into, at = null) {
|
|
|
60
60
|
}
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
// The line the model gate (kit/hooks/guard-agent-model.sh) refuses a call with starts with this, whatever follows.
|
|
64
|
+
const MODEL_GATE = 'GUARD (agent model):';
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* What the model gate said when it refused a call, or null when the text is not its refusal.
|
|
68
|
+
* `floor` is the model it says the agent runs on "or above", when it says one; nothing is read into a sentence
|
|
69
|
+
* that does not say it.
|
|
70
|
+
*/
|
|
71
|
+
export function refusalOf(text) {
|
|
72
|
+
const at = typeof text === 'string' ? text.indexOf(MODEL_GATE) : -1;
|
|
73
|
+
if (at === -1) return null;
|
|
74
|
+
const line = text.slice(at).split('\n')[0].trim();
|
|
75
|
+
const floor = line.match(/ runs on ([a-z0-9.-]+) or above/);
|
|
76
|
+
return { text: line.slice(0, 400), floor: floor ? floor[1] : null };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Which of two models is the larger, for the two questions asked of it: was a call repeated on a higher model,
|
|
80
|
+
// and which is "one model up". A name this does not know has no rank, and nothing is concluded from it.
|
|
81
|
+
const FAMILY = ['haiku', 'sonnet', 'opus', 'fable'];
|
|
82
|
+
export function modelRank(model) {
|
|
83
|
+
if (typeof model !== 'string') return null;
|
|
84
|
+
const i = FAMILY.findIndex((f) => model === f || model.includes(`-${f}-`) || model.startsWith(`${f}-`) || model.endsWith(`-${f}`));
|
|
85
|
+
return i === -1 ? null : i;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// The tool a subagent hands its result back with. The report is the call's `message`; the agent often says
|
|
89
|
+
// nothing after it, so a transcript with a handback and no closing text still holds a report.
|
|
90
|
+
const HANDBACK = 'SubagentHandback';
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* What an agent reported, from its own transcript, and where that was read from.
|
|
94
|
+
*
|
|
95
|
+
* `closing` — text the agent wrote after its last handback: its own last word, and it wins;
|
|
96
|
+
* `handback` — the message of its last handback call, when nothing was written after it;
|
|
97
|
+
* `text` — with no handback at all, the last text it wrote (what came before is narration).
|
|
98
|
+
*
|
|
99
|
+
* Text written BEFORE a handback is narration on the way to it and is never the report.
|
|
100
|
+
* @returns { text, from } — text is null when the transcript holds none of the three
|
|
101
|
+
*/
|
|
102
|
+
export function reportIn(records) {
|
|
103
|
+
let lastText = null; // the last text block of all
|
|
104
|
+
let afterHandback = null; // the last text block since the last handback
|
|
105
|
+
let handback = null;
|
|
106
|
+
for (const r of records ?? []) {
|
|
107
|
+
if (r?.type !== 'assistant' || !Array.isArray(r.message?.content)) continue;
|
|
108
|
+
for (const c of r.message.content) {
|
|
109
|
+
if (c?.type === 'text' && typeof c.text === 'string' && c.text.trim()) {
|
|
110
|
+
lastText = c.text;
|
|
111
|
+
if (handback !== null) afterHandback = c.text;
|
|
112
|
+
} else if (c?.type === 'tool_use' && c.name === HANDBACK && typeof c.input?.message === 'string' && c.input.message.trim()) {
|
|
113
|
+
handback = c.input.message;
|
|
114
|
+
afterHandback = null;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
if (afterHandback !== null) return { text: afterHandback, from: 'closing' };
|
|
119
|
+
if (handback !== null) return { text: handback, from: 'handback' };
|
|
120
|
+
return { text: lastText, from: lastText === null ? null : 'text' };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** How a report says it ended: the `confidence: high|low` line a crew agent closes with, or null. */
|
|
124
|
+
export function confidenceOf(text) {
|
|
125
|
+
const m = typeof text === 'string' ? text.trimEnd().match(/(?:^|\n)\W*confidence:\s*(high|low)\W*$/i) : null;
|
|
126
|
+
return m ? m[1].toLowerCase() : null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Tie each agent call to what came before it, where the transcript says so.
|
|
131
|
+
*
|
|
132
|
+
* `firstTry` — the gate refused a call and the session made the same call again (same agent, same task): the
|
|
133
|
+
* call that ran carries what was asked first and why it was refused.
|
|
134
|
+
* Returns nothing; it writes onto the calls.
|
|
135
|
+
*/
|
|
136
|
+
export function linkRetries(calls) {
|
|
137
|
+
const list = [...calls.values()].sort((a, b) => a.order - b.order);
|
|
138
|
+
list.forEach((c, i) => {
|
|
139
|
+
if (!c.refused) return;
|
|
140
|
+
const again = list.slice(i + 1).find((n) => n.subagentType === c.subagentType && n.description === c.description);
|
|
141
|
+
if (!again) return;
|
|
142
|
+
// A call refused twice keeps the first thing that was asked.
|
|
143
|
+
again.firstTry = c.firstTry ?? { asked: c.model, floor: c.refused.floor, text: c.refused.text };
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* `escalatedFrom` / `escalatedTo` — an agent's report closed with `confidence: low`, and the next call to the same
|
|
149
|
+
* agent after it reported asked for a higher model: the same work, done again one model up. Read from the order
|
|
150
|
+
* of the calls, the two reports and the two models; where any of the three is missing nothing is linked.
|
|
151
|
+
*/
|
|
152
|
+
export function linkEscalations(nodes) {
|
|
153
|
+
const agents = nodes.filter((n) => n.kind === 'agent' && n.order != null).sort((a, b) => a.order - b.order);
|
|
154
|
+
agents.forEach((low, i) => {
|
|
155
|
+
if (low.confidence !== 'low' || low.reportedAt == null) return;
|
|
156
|
+
const from = modelRank(low.model ?? low.modelAsked);
|
|
157
|
+
if (from === null) return;
|
|
158
|
+
const again = agents.slice(i + 1).find((n) => n.agentType === low.agentType && n.calledAt != null && n.calledAt >= low.reportedAt);
|
|
159
|
+
const to = again ? modelRank(again.modelAsked ?? again.model) : null;
|
|
160
|
+
if (to === null || to <= from || again.escalatedFrom) return;
|
|
161
|
+
again.escalatedFrom = { id: low.id, from: low.model ?? low.modelAsked, to: again.modelAsked ?? again.model };
|
|
162
|
+
low.escalatedTo = again.id;
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
63
166
|
function scanMain(records) {
|
|
64
167
|
const out = {
|
|
65
168
|
cwd: null, gitBranch: null, version: null, model: null,
|
|
@@ -110,6 +213,8 @@ function scanMain(records) {
|
|
|
110
213
|
order: out.agentCalls.size + 1,
|
|
111
214
|
wave: out.waves.get(mid),
|
|
112
215
|
calledAt: at,
|
|
216
|
+
// The model the call asked for: its own `model` field. Absent, the agent runs on its default.
|
|
217
|
+
model: typeof c.input?.model === 'string' ? c.input.model : null,
|
|
113
218
|
});
|
|
114
219
|
}
|
|
115
220
|
}
|
|
@@ -119,6 +224,12 @@ function scanMain(records) {
|
|
|
119
224
|
}
|
|
120
225
|
|
|
121
226
|
if (r?.type === 'user') {
|
|
227
|
+
// A call the model gate refused: the answer is an error that carries the gate's own line. No agent started.
|
|
228
|
+
for (const c of Array.isArray(r.message?.content) ? r.message.content : []) {
|
|
229
|
+
if (c?.type !== 'tool_result' || c.is_error !== true || !out.agentCalls.has(c.tool_use_id)) continue;
|
|
230
|
+
const said = refusalOf(textOf(typeof c.content === 'string' ? [{ type: 'text', text: c.content }] : c.content));
|
|
231
|
+
if (said) out.agentCalls.get(c.tool_use_id).refused = said;
|
|
232
|
+
}
|
|
122
233
|
out.userTurns += 1;
|
|
123
234
|
harvestCompletions(textOf(r.message?.content), out.completions, at);
|
|
124
235
|
}
|
|
@@ -205,11 +316,13 @@ export function openCalls(records) {
|
|
|
205
316
|
}
|
|
206
317
|
|
|
207
318
|
/** Roll one agent's own transcript into the numbers its node shows. */
|
|
208
|
-
async function scanAgent(file, usage = null) {
|
|
319
|
+
async function scanAgent(file, usage = null, agentId = null) {
|
|
209
320
|
const { records } = await readAll(file);
|
|
210
|
-
usage?.add(records);
|
|
321
|
+
usage?.add(records, agentId ?? 'agents:unnamed');
|
|
211
322
|
const stats = {
|
|
212
323
|
tools: {}, toolCount: 0, lastTool: null, errors: 0,
|
|
324
|
+
model: null, // the model its own transcript names: what it actually ran on
|
|
325
|
+
lastText: null, // the end of its report, for the line a report closes with
|
|
213
326
|
tokens: null, startedAt: null, endedAt: null, turns: 0,
|
|
214
327
|
owns: [], // tool_use ids this agent emitted — how nesting is resolved
|
|
215
328
|
};
|
|
@@ -224,6 +337,8 @@ async function scanAgent(file, usage = null) {
|
|
|
224
337
|
}
|
|
225
338
|
if (r?.type === 'assistant') {
|
|
226
339
|
stats.turns += 1;
|
|
340
|
+
const mdl = r.message?.model;
|
|
341
|
+
if (typeof mdl === 'string' && mdl && mdl !== '<synthetic>') stats.model = mdl;
|
|
227
342
|
const u = r.message?.usage;
|
|
228
343
|
if (u && u.cache_read_input_tokens != null) {
|
|
229
344
|
stats.tokens = (u.input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0);
|
|
@@ -251,6 +366,8 @@ async function scanAgent(file, usage = null) {
|
|
|
251
366
|
stats.durationMs = stats.startedAt !== null && stats.endedAt !== null
|
|
252
367
|
? stats.endedAt - stats.startedAt
|
|
253
368
|
: null;
|
|
369
|
+
// The report, wherever the agent put it: the confidence line is its last line, handback included.
|
|
370
|
+
stats.lastText = reportIn(records).text?.slice(-400) ?? null;
|
|
254
371
|
stats.midTurn = midTurn(records);
|
|
255
372
|
stats.open = openCalls(records);
|
|
256
373
|
return stats;
|
|
@@ -274,7 +391,7 @@ async function readAgentDir(subagentsDir, usage = null) {
|
|
|
274
391
|
// `stats` needs the transcript read; a missing file is a normal state (the
|
|
275
392
|
// agent has not written yet), so absence is a null rather than a throw.
|
|
276
393
|
let stats = null;
|
|
277
|
-
if (mtime !== null) stats = await scanAgent(jsonl, usage);
|
|
394
|
+
if (mtime !== null) stats = await scanAgent(jsonl, usage, agentId);
|
|
278
395
|
|
|
279
396
|
out.push({
|
|
280
397
|
agentId,
|
|
@@ -310,6 +427,7 @@ export async function buildGraph(session, { staleMs = STALE_MS } = {}) {
|
|
|
310
427
|
const { records, malformed } = await readAll(session.file);
|
|
311
428
|
const main = scanMain(records);
|
|
312
429
|
// What the session spent, its agents' tokens included and each response counted once.
|
|
430
|
+
linkRetries(main.agentCalls);
|
|
313
431
|
const usage = new Usage().add(records);
|
|
314
432
|
const agents = await readAgentDir(session.subagentsDir, usage);
|
|
315
433
|
const now = Date.now();
|
|
@@ -341,7 +459,7 @@ export async function buildGraph(session, { staleMs = STALE_MS } = {}) {
|
|
|
341
459
|
tokens: contextFill(records),
|
|
342
460
|
startedAt: main.startedAt,
|
|
343
461
|
endedAt: main.updatedAt,
|
|
344
|
-
usage:
|
|
462
|
+
usage: withTimes(usage, records),
|
|
345
463
|
// Commands sent to the background that no notice has ended, oldest first. A session that is over has none
|
|
346
464
|
// running whatever this says: the page shows them only while the session is live.
|
|
347
465
|
backgroundCommands: [...main.background]
|
|
@@ -394,7 +512,16 @@ export async function buildGraph(session, { staleMs = STALE_MS } = {}) {
|
|
|
394
512
|
spawnDepth: a.spawnDepth,
|
|
395
513
|
workflow: a.workflow,
|
|
396
514
|
toolUseId: a.toolUseId,
|
|
397
|
-
model:
|
|
515
|
+
// The model it ran on: what its own transcript names, and failing that what the launch answer resolved.
|
|
516
|
+
model: a.stats?.model ?? link?.model ?? null,
|
|
517
|
+
// The model the call asked for; null when the call named none and the agent ran on its default.
|
|
518
|
+
modelAsked: call?.model ?? null,
|
|
519
|
+
// Set when the model gate refused this call the first time it was made: what was asked then, and why not.
|
|
520
|
+
firstTry: call?.firstTry ?? null,
|
|
521
|
+
// `confidence: high|low`, the line a crew agent's report closes with; null when it has none.
|
|
522
|
+
confidence: confidenceOf(a.stats?.lastText ?? null),
|
|
523
|
+
// Its own share of the session's tokens, and what they cost at list price.
|
|
524
|
+
usage: usage.of(a.agentId),
|
|
398
525
|
launchStatus: link?.status ?? null,
|
|
399
526
|
// Started in the background, and its transcript stops in the middle of a turn. Such an agent can be quiet
|
|
400
527
|
// for a long time inside one tool call; whether it is still working is the live session's to say, so the
|
|
@@ -426,6 +553,8 @@ export async function buildGraph(session, { staleMs = STALE_MS } = {}) {
|
|
|
426
553
|
edges.push({ id: `${parentId}->${a.agentId}`, source: parentId, target: a.agentId, kind: 'spawn' });
|
|
427
554
|
}
|
|
428
555
|
|
|
556
|
+
linkEscalations(nodes);
|
|
557
|
+
|
|
429
558
|
// A workflow run is a real container, not a rendering trick: its agents were
|
|
430
559
|
// spawned by one orchestration script, not by the session directly. Hanging
|
|
431
560
|
// 243 of them straight off the root produced a graph nobody could read, and
|
|
@@ -598,9 +727,10 @@ export async function agentDetail(session, agentId) {
|
|
|
598
727
|
}
|
|
599
728
|
}
|
|
600
729
|
|
|
601
|
-
// The report
|
|
602
|
-
// narration between tool calls, which is progress rather than conclusion.
|
|
603
|
-
const
|
|
730
|
+
// The report: what the agent wrote last, or what it handed back when it wrote nothing after that. Earlier
|
|
731
|
+
// blocks are narration between tool calls, which is progress rather than conclusion.
|
|
732
|
+
const reported = reportIn(records);
|
|
733
|
+
const report = reported.text;
|
|
604
734
|
|
|
605
735
|
return {
|
|
606
736
|
agentId,
|
|
@@ -611,9 +741,11 @@ export async function agentDetail(session, agentId) {
|
|
|
611
741
|
spawnDepth: meta.spawnDepth ?? null,
|
|
612
742
|
prompt,
|
|
613
743
|
report,
|
|
744
|
+
// Where the report was read from: 'closing', 'handback' or 'text' (see reportIn); null when there is none.
|
|
745
|
+
reportFrom: reported.from,
|
|
614
746
|
// Kept apart so the UI can show progress for an agent that has not
|
|
615
747
|
// reported yet, without pretending the narration is a conclusion.
|
|
616
|
-
narration: texts.
|
|
748
|
+
narration: texts.filter((t) => t.text !== report).map((t) => t.text),
|
|
617
749
|
thinking: lastThinking,
|
|
618
750
|
timeline,
|
|
619
751
|
lastError,
|