amicus 4.3.0 → 4.4.1
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/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +64 -0
- package/README.md +6 -3
- package/docs/DISTRIBUTION.md +234 -0
- package/docs/ROADMAP.md +200 -0
- package/docs/SHIMS.md +62 -0
- package/docs/architecture.md +104 -0
- package/docs/configuration.md +371 -0
- package/docs/council.md +911 -0
- package/docs/doc-system.md +92 -0
- package/docs/electron-testing.md +471 -0
- package/docs/jsdoc-setup.md +75 -0
- package/docs/opencode-integration.md +114 -0
- package/docs/publishing.md +60 -0
- package/docs/schemas.md +55 -0
- package/docs/testing.md +589 -0
- package/docs/troubleshooting.md +298 -0
- package/docs/usage.md +699 -0
- package/electron/fold.js +1 -1
- package/electron/ipc-workspace.js +283 -0
- package/electron/main.js +31 -1
- package/electron/preload-workspace.js +40 -0
- package/electron/setup-ui-aliases.js +6 -6
- package/electron/workspace-shell.js +85 -0
- package/electron/workspace-ui/index.html +111 -0
- package/electron/workspace-ui/live-model.js +112 -0
- package/electron/workspace-ui/md-lite.js +163 -0
- package/electron/workspace-ui/workspace-app.js +240 -0
- package/electron/workspace-ui/workspace-matrix.js +249 -0
- package/electron/workspace-ui/workspace-panels.js +237 -0
- package/electron/workspace-ui/workspace-render.js +277 -0
- package/electron/workspace-ui/workspace-verbs.js +293 -0
- package/electron/workspace-ui/workspace.css +172 -0
- package/package.json +8 -3
- package/schemas/council-run-live.schema.json +25 -1
- package/schemas/council-run.schema.json +34 -0
- package/schemas/progress.schema.json +26 -1
- package/schemas/spend.schema.json +52 -4
- package/skills/second-opinion/MODEL-NOTES.md +53 -5
- package/src/cli-handlers-council-run.js +25 -3
- package/src/cli-handlers-spend.js +50 -5
- package/src/cli-handlers-watch.js +48 -10
- package/src/cli.js +4 -2
- package/src/council/briefings-debate.js +27 -7
- package/src/council/briefings-stage2.js +155 -25
- package/src/council/briefings.js +59 -3
- package/src/council/findings.js +236 -9
- package/src/council/parse-stage2.js +10 -2
- package/src/council/report.js +19 -8
- package/src/council/run-assemble.js +42 -1
- package/src/council/run-budget.js +277 -0
- package/src/council/run-chair.js +4 -1
- package/src/council/run-debate.js +4 -2
- package/src/council/run-finalize.js +102 -0
- package/src/council/run-launch.js +73 -7
- package/src/council/run-server.js +248 -0
- package/src/council/run-stage2.js +118 -0
- package/src/council/run-stages.js +148 -113
- package/src/council/run-state.js +23 -1
- package/src/council/run.js +52 -53
- package/src/council/tally.js +10 -0
- package/src/headless.js +519 -17
- package/src/mcp-council-awareness.js +53 -3
- package/src/observe/council-legs.js +240 -0
- package/src/observe/live-doc.js +39 -4
- package/src/observe/watch-render.js +23 -1
- package/src/opencode-client.js +15 -3
- package/src/sidecar/child-sessions.js +197 -0
- package/src/sidecar/conversation-mirror.js +111 -37
- package/src/sidecar/fanout-budget.js +71 -0
- package/src/sidecar/fanout-leg-fallback.js +69 -21
- package/src/sidecar/fanout-leg.js +29 -1
- package/src/sidecar/fanout-signals.js +61 -0
- package/src/sidecar/fanout-wave-io.js +75 -0
- package/src/sidecar/fanout.js +65 -81
- package/src/sidecar/progress-fields.js +26 -4
- package/src/sidecar/progress.js +8 -1
- package/src/sidecar/session-utils.js +23 -14
- package/src/sidecar/tool-part.js +196 -0
- package/src/sidecar/workspace-window.js +62 -0
- package/src/spend-query.js +33 -6
- package/src/utils/env-num.js +42 -0
- package/src/utils/lifecycle.js +37 -1
- package/src/utils/path-fence.js +120 -0
- package/src/utils/pricing.js +114 -9
- package/src/utils/server-setup.js +79 -1
- package/src/utils/spend-ledger.js +24 -3
- package/src/workspace/artifact-guard.js +208 -0
- package/src/workspace/blind-mode.js +32 -0
- package/src/workspace/fold-format.js +124 -0
- package/src/workspace/live-normalize.js +169 -0
- package/src/workspace/matrix-model.js +94 -0
- package/src/workspace/run-detail.js +229 -0
- package/src/workspace/run-scan.js +148 -0
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
// src/sidecar/child-sessions.js
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @module sidecar/child-sessions
|
|
6
|
+
* v4.4.1 CA-1 (B4) — enumerate a leg's CHILD (subagent) sessions and total
|
|
7
|
+
* their spend, so it can be attributed to the parent leg instead of vanishing.
|
|
8
|
+
*
|
|
9
|
+
* THE DEFECT. A leg that calls the `task` tool spawns a child OpenCode session.
|
|
10
|
+
* OpenCode bills it separately and does NOT roll it into the parent session's
|
|
11
|
+
* cost, and amicus never enumerated it — so it was invisible to every total the
|
|
12
|
+
* product prints. Measured across the four recorded paid runs: **$0.492506**
|
|
13
|
+
* ($0.021460 in `council-wsgate01`, $0.471046 in `council-wsgate02`).
|
|
14
|
+
* `wsgate01` is the honest limit case: all 7 legs `source: 'reported'`,
|
|
15
|
+
* `unpricedLegs: 0`, and the run still 7.1% short — 100% of that gap was one
|
|
16
|
+
* `explore` child session.
|
|
17
|
+
*
|
|
18
|
+
* WHY IT IS SAFE TO DO THIS NOW. Both blockers named in
|
|
19
|
+
* `cost-pipeline-fix-report.md` §5.4/§5.7 are closed. The client capability was
|
|
20
|
+
* already there (`getChildren`, src/opencode-client.js — only `directory`
|
|
21
|
+
* scoping was missing, backlog LC-7, fixed with this change), and the
|
|
22
|
+
* premature-completion fix (`dcb0792`) means a `task` part goes terminal only
|
|
23
|
+
* once its child session ends, so enumerating at finalization can no longer
|
|
24
|
+
* capture a partial child cost and trade a silent zero for a silent floor.
|
|
25
|
+
*
|
|
26
|
+
* THE HONESTY RULE. Never fabricate a number. `complete: false` is returned
|
|
27
|
+
* whenever any part of the walk could not be carried out — an API failure, a
|
|
28
|
+
* bound, or a child whose spend cannot be stated — and the caller turns that
|
|
29
|
+
* into the leg's existing `subtreeUnknown` flag rather than into a zero. There
|
|
30
|
+
* is deliberately no fourth honesty concept here: what this module produces is
|
|
31
|
+
* either an attributable amount or the flag that already exists for "the
|
|
32
|
+
* subtree is not knowable".
|
|
33
|
+
*
|
|
34
|
+
* A child's price comes from the SAME field the parent's does: the assistant
|
|
35
|
+
* messages' `info.cost`. It is never estimated from a catalog — the SDK's
|
|
36
|
+
* Session record carries no model id, so estimating one would mean guessing
|
|
37
|
+
* which route billed it, and a guess is exactly what this module exists to
|
|
38
|
+
* avoid. A child that shows real tokens but reports no cost is therefore
|
|
39
|
+
* `priced: false` and makes the subtree incomplete (the B2 rule, one level
|
|
40
|
+
* down): work happened, and we cannot state what it cost.
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
const { getChildren, getMessages } = require('../opencode-client');
|
|
44
|
+
const { createMirrorState, mirrorUsageOnly } = require('./conversation-mirror');
|
|
45
|
+
const { sumPerMessageUsage } = require('../utils/pricing');
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Bounds. Hard constants rather than env knobs: they exist to stop a pathological
|
|
49
|
+
* walk, not to be tuned. Real subagent trees observed in the corpus are one child
|
|
50
|
+
* deep and one wide; 4/64 is orders of magnitude of headroom, and exceeding either
|
|
51
|
+
* is reported as INCOMPLETE rather than silently truncated.
|
|
52
|
+
*/
|
|
53
|
+
const SUBTREE_MAX_DEPTH = 4;
|
|
54
|
+
const SUBTREE_MAX_SESSIONS = 64;
|
|
55
|
+
|
|
56
|
+
/** Resolve, or reject after `ms`. `ms <= 0` passes the promise through untouched. */
|
|
57
|
+
function withDeadline(promise, ms) {
|
|
58
|
+
if (!ms || ms <= 0) { return promise; }
|
|
59
|
+
let timer;
|
|
60
|
+
return Promise.race([
|
|
61
|
+
promise.then((v) => { clearTimeout(timer); return v; },
|
|
62
|
+
(e) => { clearTimeout(timer); throw e; }),
|
|
63
|
+
new Promise((_r, reject) => { timer = setTimeout(() => reject(new Error('subtree call timed out')), ms); }),
|
|
64
|
+
]);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function emptyTokens() {
|
|
68
|
+
return { input: 0, output: 0, reasoning: 0, cacheRead: 0, cacheWrite: 0 };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Total one child session's own usage from its assistant messages — the same
|
|
73
|
+
* capture path the parent leg uses (mirrorUsageOnly keys the latest snapshot per
|
|
74
|
+
* message id, so a re-read can never double-count).
|
|
75
|
+
* @returns {{tokens: object, costReported: number, priced: boolean}}
|
|
76
|
+
*/
|
|
77
|
+
function usageOfSession(messages) {
|
|
78
|
+
const state = createMirrorState();
|
|
79
|
+
mirrorUsageOnly(messages, state);
|
|
80
|
+
const totals = sumPerMessageUsage(state.usageByMsg);
|
|
81
|
+
return {
|
|
82
|
+
tokens: totals.tokens,
|
|
83
|
+
costReported: totals.costReported,
|
|
84
|
+
// "We can state this session's spend." Only a billed amount states it.
|
|
85
|
+
// Tokens with no cost are the child-level form of the B2 lie (`0 × catalog
|
|
86
|
+
// = estimated $0`) and cannot be priced here at all, because the SDK's
|
|
87
|
+
// Session record carries no model id — so they read as unknown, which is
|
|
88
|
+
// what they are. A session with neither tokens nor cost is not free either:
|
|
89
|
+
// we saw nothing, and nothing is not zero.
|
|
90
|
+
priced: totals.costReported > 0,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Walk `rootSessionId`'s child sessions (breadth-first, bounded, cycle-proof)
|
|
96
|
+
* and total their spend.
|
|
97
|
+
*
|
|
98
|
+
* @param {import('@opencode-ai/sdk').OpencodeClient} client
|
|
99
|
+
* @param {string} rootSessionId the LEG's own session id (never itself counted)
|
|
100
|
+
* @param {object} [opts]
|
|
101
|
+
* @param {string} [opts.directory] project dir; threaded into every call (#47/LC-7)
|
|
102
|
+
* @param {number} [opts.callTimeoutMs] per-call deadline; 0/absent = no extra timer
|
|
103
|
+
* @param {object} [opts.logger] optional logger for best-effort debug lines
|
|
104
|
+
* @returns {Promise<{sessions: Array<{id: string, tokens: object, costReported: number, priced: boolean}>,
|
|
105
|
+
* tokens: object, costReported: number, complete: boolean}>}
|
|
106
|
+
*/
|
|
107
|
+
async function collectSubtreeUsage(client, rootSessionId, opts = {}) {
|
|
108
|
+
const { directory, callTimeoutMs, logger } = opts;
|
|
109
|
+
const dirArgs = directory === undefined ? [] : [directory];
|
|
110
|
+
const tokens = emptyTokens();
|
|
111
|
+
const sessions = [];
|
|
112
|
+
const visited = new Set([rootSessionId]);
|
|
113
|
+
let costReported = 0;
|
|
114
|
+
let complete = true;
|
|
115
|
+
let frontier = [rootSessionId];
|
|
116
|
+
|
|
117
|
+
for (let depth = 0; depth < SUBTREE_MAX_DEPTH && frontier.length > 0; depth++) {
|
|
118
|
+
const next = [];
|
|
119
|
+
for (const parentId of frontier) {
|
|
120
|
+
let kids;
|
|
121
|
+
try {
|
|
122
|
+
kids = await withDeadline(getChildren(client, parentId, ...dirArgs), callTimeoutMs);
|
|
123
|
+
} catch (err) {
|
|
124
|
+
// Could not enumerate: the subtree is unknown, not empty.
|
|
125
|
+
complete = false;
|
|
126
|
+
if (logger) { logger.debug('subtree: getChildren failed', { parentId, error: err.message }); }
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
for (const kid of Array.isArray(kids) ? kids : []) {
|
|
130
|
+
const id = kid && kid.id;
|
|
131
|
+
if (!id || visited.has(id)) { continue; }
|
|
132
|
+
if (visited.size > SUBTREE_MAX_SESSIONS) { complete = false; break; }
|
|
133
|
+
visited.add(id);
|
|
134
|
+
|
|
135
|
+
let messages;
|
|
136
|
+
try {
|
|
137
|
+
messages = await withDeadline(getMessages(client, id, ...dirArgs), callTimeoutMs);
|
|
138
|
+
} catch (err) {
|
|
139
|
+
complete = false;
|
|
140
|
+
if (logger) { logger.debug('subtree: getMessages failed', { sessionId: id, error: err.message }); }
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
const u = usageOfSession(messages);
|
|
144
|
+
sessions.push({ id, tokens: u.tokens, costReported: u.costReported, priced: u.priced });
|
|
145
|
+
if (u.priced) {
|
|
146
|
+
costReported += u.costReported;
|
|
147
|
+
for (const k of Object.keys(tokens)) { tokens[k] += u.tokens[k] || 0; }
|
|
148
|
+
} else {
|
|
149
|
+
// The child exists and we read it, but its spend is not stateable.
|
|
150
|
+
complete = false;
|
|
151
|
+
}
|
|
152
|
+
next.push(id);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
// The frontier is non-empty at the last permitted depth only if there may be
|
|
156
|
+
// another level we are choosing not to walk — say so rather than imply the
|
|
157
|
+
// walk finished.
|
|
158
|
+
if (depth === SUBTREE_MAX_DEPTH - 1 && next.length > 0) { complete = false; }
|
|
159
|
+
frontier = next;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return { sessions, tokens, costReported, complete };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Is this leg's subtree spend UNKNOWN, given everything we observed?
|
|
167
|
+
*
|
|
168
|
+
* Split out as a pure predicate because the calibration is the whole argument,
|
|
169
|
+
* and getting it wrong in either direction is a real defect:
|
|
170
|
+
*
|
|
171
|
+
* - Too eager and the flag cries wolf. An OpenCode server that does not serve
|
|
172
|
+
* `/session/{id}/children` fails EVERY walk, and a naive `!walkComplete`
|
|
173
|
+
* would then mark every leg of every run inexact forever — which is not
|
|
174
|
+
* honesty, it is noise that trains the reader to ignore the one run where it
|
|
175
|
+
* matters.
|
|
176
|
+
* - Too lax and it is the silent under-count the flag exists to kill.
|
|
177
|
+
*
|
|
178
|
+
* So it keys on EVIDENCE. A failed walk with no evidence of a subagent at all
|
|
179
|
+
* (no child found, no `task` call recorded) asserts nothing and flags nothing —
|
|
180
|
+
* exactly the pre-CA-1 behaviour. Any positive evidence of a subtree that the
|
|
181
|
+
* walk did not fully account for flags it.
|
|
182
|
+
*
|
|
183
|
+
* @param {{walkComplete: boolean, sessionsFound: number, subagentCalls: number}} obs
|
|
184
|
+
* @returns {boolean}
|
|
185
|
+
*/
|
|
186
|
+
function subtreeIsUnknown({ walkComplete, sessionsFound, subagentCalls }) {
|
|
187
|
+
const found = sessionsFound > 0;
|
|
188
|
+
const expected = subagentCalls > 0;
|
|
189
|
+
// A clean walk still cannot be believed when it contradicts the other
|
|
190
|
+
// observation: a leg that demonstrably called `task` and yet has no child
|
|
191
|
+
// session means one of the two readings is wrong, and "there was nothing" is
|
|
192
|
+
// not the reading this evidence supports.
|
|
193
|
+
if (walkComplete) { return expected && !found; }
|
|
194
|
+
return found || expected;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
module.exports = { collectSubtreeUsage, subtreeIsUnknown, SUBTREE_MAX_DEPTH, SUBTREE_MAX_SESSIONS };
|
|
@@ -17,6 +17,9 @@
|
|
|
17
17
|
// bump in the headless idle detector.
|
|
18
18
|
const MAX_TOOL_CALLS = 2000;
|
|
19
19
|
|
|
20
|
+
const toolPart = require('./tool-part');
|
|
21
|
+
const { isToolPart, toolPartName, toolPartInput, toolPartStatus, isToolPartSettled } = toolPart;
|
|
22
|
+
|
|
20
23
|
/** Fresh cursor for a session's mirror. */
|
|
21
24
|
function createMirrorState() {
|
|
22
25
|
return {
|
|
@@ -24,7 +27,8 @@ function createMirrorState() {
|
|
|
24
27
|
toolCalls: [], // [{id,name,input}] — capped at MAX_TOOL_CALLS (most-recent-N)
|
|
25
28
|
seenToolCallIds: new Set(), // stable dedup identity for tool calls (survives the cap)
|
|
26
29
|
seenToolResultIds: new Set(),
|
|
27
|
-
|
|
30
|
+
settledToolCallIds: new Set(), // ids that reached a TERMINAL state.status (v4.4 B4 part 1)
|
|
31
|
+
pendingToolCalls: new Map(), // id -> {id,name,firstSeenAt} — tool call not yet TERMINAL (B53/B4)
|
|
28
32
|
receivingReported: false,
|
|
29
33
|
output: '', // accumulated assistant text
|
|
30
34
|
seenReasoningParts: new Map(), // partId -> last captured reasoning length
|
|
@@ -34,16 +38,63 @@ function createMirrorState() {
|
|
|
34
38
|
}
|
|
35
39
|
|
|
36
40
|
/**
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
* is the live source of truth.
|
|
42
|
-
* @param {object} state from createMirrorState()
|
|
43
|
-
* @returns {Array<{id:string,name:string,firstSeenAt:string}>}
|
|
41
|
+
* Capture one assistant message's usage snapshot into `state.usageByMsg`.
|
|
42
|
+
* The poll loop re-reads ALL messages every poll, so the latest snapshot per
|
|
43
|
+
* message id wins (keyed Map, never additive) — see pricing.sumPerMessageUsage.
|
|
44
|
+
* @returns {boolean} true when this message carried a usage payload
|
|
44
45
|
*/
|
|
45
|
-
function
|
|
46
|
-
|
|
46
|
+
function captureMsgUsage(msg, state) {
|
|
47
|
+
if (msg.info.tokens || typeof msg.info.cost === 'number') {
|
|
48
|
+
state.usageByMsg.set(msg.info.id, { tokens: msg.info.tokens, cost: msg.info.cost });
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* USAGE-ONLY mirror pass (v4.4 B1). Captures `info.tokens`/`info.cost` from a
|
|
56
|
+
* fresh getMessages() snapshot and NOTHING else — no appendLines, no
|
|
57
|
+
* `state.output` growth, no progress updates, no pending-tool bookkeeping.
|
|
58
|
+
*
|
|
59
|
+
* This exists because the headless poll loop's fast-path exits (trailing fold
|
|
60
|
+
* marker, SDK `idle`) break BEFORE OpenCode stamps usage at finalization, so a
|
|
61
|
+
* bounded post-loop re-read is required to see it. Calling the full
|
|
62
|
+
* mirrorMessages() there would append the already-mirrored assistant text to
|
|
63
|
+
* conversation.jsonl a second time; this function cannot, because it never
|
|
64
|
+
* touches seenTextParts/output at all.
|
|
65
|
+
* @param {Array} messages getMessages() snapshot
|
|
66
|
+
* @param {object} state from createMirrorState() (only usageByMsg is mutated)
|
|
67
|
+
* @returns {number} count of messages whose usage was captured
|
|
68
|
+
*/
|
|
69
|
+
function mirrorUsageOnly(messages, state) {
|
|
70
|
+
const list = Array.isArray(messages) ? messages : [];
|
|
71
|
+
let captured = 0;
|
|
72
|
+
for (const msg of list) {
|
|
73
|
+
if (!msg || !msg.info || msg.info.role !== 'assistant') { continue; }
|
|
74
|
+
if (captureMsgUsage(msg, state)) { captured += 1; }
|
|
75
|
+
}
|
|
76
|
+
return captured;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Does EVERY assistant message in this snapshot carry a usage payload we can
|
|
81
|
+
* act on — a billed cost, or genuinely observed tokens (v4.4 B1 early-break)?
|
|
82
|
+
*
|
|
83
|
+
* Keyed on `hasObservedTokens` rather than "tokens object exists" so an
|
|
84
|
+
* all-zero placeholder block does not satisfy the predicate — that is exactly
|
|
85
|
+
* the pre-finalization state the settle loop is waiting out. A legitimately
|
|
86
|
+
* free local seat still reports real token counts, so it satisfies this on the
|
|
87
|
+
* first re-read and never pays the full settle window.
|
|
88
|
+
* @param {Array} messages
|
|
89
|
+
* @returns {boolean} false when there are no assistant messages at all
|
|
90
|
+
*/
|
|
91
|
+
function allAssistantUsagePresent(messages) {
|
|
92
|
+
const { hasObservedTokens } = require('../utils/pricing');
|
|
93
|
+
const list = Array.isArray(messages) ? messages : [];
|
|
94
|
+
const assistants = list.filter((m) => m && m.info && m.info.role === 'assistant');
|
|
95
|
+
if (assistants.length === 0) { return false; }
|
|
96
|
+
return assistants.every((m) => (typeof m.info.cost === 'number' && m.info.cost > 0)
|
|
97
|
+
|| hasObservedTokens(m.info.tokens));
|
|
47
98
|
}
|
|
48
99
|
|
|
49
100
|
/**
|
|
@@ -68,9 +119,7 @@ function mirrorMessages(messages, state, opts = {}) {
|
|
|
68
119
|
// Track assistant message state
|
|
69
120
|
if (role === 'assistant') {
|
|
70
121
|
currentAssistantMsgId = msg.info.id;
|
|
71
|
-
|
|
72
|
-
state.usageByMsg.set(msg.info.id, { tokens: msg.info.tokens, cost: msg.info.cost });
|
|
73
|
-
}
|
|
122
|
+
captureMsgUsage(msg, state);
|
|
74
123
|
// Check for errors — capture for result propagation
|
|
75
124
|
if (msg.info.error) {
|
|
76
125
|
sessionError = (msg.info.error.data && msg.info.error.data.message)
|
|
@@ -99,29 +148,49 @@ function mirrorMessages(messages, state, opts = {}) {
|
|
|
99
148
|
progressUpdates.push({ stage: 'receiving', extra: { messagesReceived: 1 } });
|
|
100
149
|
}
|
|
101
150
|
}
|
|
102
|
-
} else if ((part
|
|
103
|
-
|
|
104
|
-
state.
|
|
105
|
-
|
|
106
|
-
//
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
151
|
+
} else if (isToolPart(part)) {
|
|
152
|
+
// v4.4 B4 part 1: this branch runs on EVERY poll, not only first sight,
|
|
153
|
+
// because a tool call's `state.status` transitions in place — that
|
|
154
|
+
// transition is the only signal OpenCode gives that the call finished
|
|
155
|
+
// (there is no tool_result part). Appending stays first-sight-only.
|
|
156
|
+
const toolName = toolPartName(part);
|
|
157
|
+
const firstSight = !state.seenToolCallIds.has(part.id);
|
|
158
|
+
if (firstSight) {
|
|
159
|
+
const toolCall = { id: part.id, name: toolName, input: toolPartInput(part) };
|
|
160
|
+
state.seenToolCallIds.add(part.id);
|
|
161
|
+
state.toolCalls.push(toolCall);
|
|
162
|
+
// Bound growth: keep the most recent N tool-call payloads (BL-4). Dedup is the
|
|
163
|
+
// Set above, so dropping the oldest here never causes a re-append.
|
|
164
|
+
if (state.toolCalls.length > MAX_TOOL_CALLS) { state.toolCalls.shift(); }
|
|
165
|
+
appendLines.push({ role: 'assistant', type: 'tool_use', toolCall, timestamp: now() });
|
|
166
|
+
|
|
167
|
+
// Update progress on tool-call detection
|
|
168
|
+
progressUpdates.push({
|
|
169
|
+
stage: 'receiving',
|
|
170
|
+
extra: {
|
|
171
|
+
messagesReceived: state.toolCalls.length,
|
|
172
|
+
latestTool: toolName || undefined,
|
|
173
|
+
stageLabel: toolName ? `Calling tool: ${toolName}` : 'Executing tool call...',
|
|
174
|
+
},
|
|
175
|
+
});
|
|
176
|
+
state.receivingReported = true;
|
|
177
|
+
}
|
|
178
|
+
// Pending until the status is positively observed TERMINAL. firstSeenAt
|
|
179
|
+
// is captured once — never bumped while the call stays non-terminal, so
|
|
180
|
+
// B53's "pending for Ns" reason string stays honest.
|
|
181
|
+
if (isToolPartSettled(part)) {
|
|
182
|
+
state.pendingToolCalls.delete(part.id);
|
|
183
|
+
state.settledToolCallIds.add(part.id);
|
|
184
|
+
} else if (!state.settledToolCallIds.has(part.id)) {
|
|
185
|
+
const prior = state.pendingToolCalls.get(part.id);
|
|
186
|
+
state.pendingToolCalls.set(part.id, {
|
|
187
|
+
id: part.id, name: toolName,
|
|
188
|
+
// undefined = legacy shape, status unknown. Kept explicitly so
|
|
189
|
+
// getLiveToolCalls can tell "still running" from "no idea".
|
|
190
|
+
status: toolPartStatus(part),
|
|
191
|
+
firstSeenAt: prior ? prior.firstSeenAt : now(),
|
|
192
|
+
});
|
|
193
|
+
}
|
|
125
194
|
} else if (part.type === 'tool_result') {
|
|
126
195
|
// Dedup: append only on first sight (fixes latent double-log bug in headless poll loop)
|
|
127
196
|
if (!state.seenToolResultIds.has(partId)) {
|
|
@@ -199,4 +268,9 @@ function logMessage(conversationPath, message) {
|
|
|
199
268
|
fs.appendFileSync(conversationPath, JSON.stringify(message) + '\n', { mode: 0o600 });
|
|
200
269
|
}
|
|
201
270
|
|
|
202
|
-
module.exports = { createMirrorState, mirrorMessages, logMessage,
|
|
271
|
+
module.exports = { createMirrorState, mirrorMessages, logMessage,
|
|
272
|
+
mirrorUsageOnly, allAssistantUsagePresent,
|
|
273
|
+
// Re-exported from ./tool-part so callers have ONE import surface for the
|
|
274
|
+
// mirror's tool-call model (that module exists separately to keep this file
|
|
275
|
+
// under the 300-line size gate).
|
|
276
|
+
...toolPart };
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// src/sidecar/fanout-budget.js
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @module sidecar/fanout-budget
|
|
6
|
+
* runFanout's pre-flight spend gate (§1b), extracted from src/sidecar/fanout.js —
|
|
7
|
+
* which sits three lines under the 300-line size gate and had no room for the
|
|
8
|
+
* v4.4 reservation seam. Behaviour of the extracted half is unchanged; the
|
|
9
|
+
* reservation is the only addition.
|
|
10
|
+
*
|
|
11
|
+
* WHY THE RESERVATION EXISTS (v4.4 cost-council finding 1). `checkBudget`
|
|
12
|
+
* compares the wave's pre-flight ESTIMATE against a `maxCost` NUMBER that the
|
|
13
|
+
* caller read at some earlier moment. The council driver launches Stage-1's seat
|
|
14
|
+
* wave and critic wave concurrently under a single `Promise.all`, and each
|
|
15
|
+
* launcher read `remainingBudget()` before EITHER wave's legs had been recorded
|
|
16
|
+
* — so both observed the full, unreduced allowance and both could pass a ceiling
|
|
17
|
+
* that only one of them fits under. The read is not the claim.
|
|
18
|
+
*
|
|
19
|
+
* `options.reserveBudget(estimate) -> boolean` closes that: it is a SYNCHRONOUS
|
|
20
|
+
* read-and-claim against the allowance not already claimed by a sibling wave
|
|
21
|
+
* that is mid-launch. Being synchronous is the whole guarantee — the event loop
|
|
22
|
+
* cannot interleave two callers inside it, so the second caller necessarily sees
|
|
23
|
+
* the first caller's claim. See src/council/run-budget.js for the ledger.
|
|
24
|
+
*
|
|
25
|
+
* It is OPT-IN: every non-council caller (the `amicus fanout` CLI, `amicus run`)
|
|
26
|
+
* omits it and gets the byte-identical pre-v4.4 gate.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @param {Array<{modelInput,model,pricing}>} okLegs legs that actually routed
|
|
31
|
+
* @param {object} options runFanout options (maxCost, maxCostPerMtok, noCostGate,
|
|
32
|
+
* promptMeta/prompt, and the optional `reserveBudget` claim function)
|
|
33
|
+
* @returns {{ok:true, estimate?:number}|{ok:false, message:string, hint:string}}
|
|
34
|
+
*/
|
|
35
|
+
function preflightBudget(okLegs, options) {
|
|
36
|
+
// `--no-cost-gate` is a WHOLE-RUN opt-out of BOTH guards (an intentional
|
|
37
|
+
// o3-class council), so it must also skip the reservation — otherwise the
|
|
38
|
+
// council would still serialize its allowance for a ceiling it has disowned.
|
|
39
|
+
if (options.noCostGate) { return { ok: true }; }
|
|
40
|
+
|
|
41
|
+
const { checkBudget, formatBudgetError } = require('./budget');
|
|
42
|
+
const { loadConfig } = require('../utils/config');
|
|
43
|
+
const cfg = loadConfig() || {};
|
|
44
|
+
const maxCostPerMtok = options.maxCostPerMtok !== undefined ? options.maxCostPerMtok : cfg.maxCostPerMtok;
|
|
45
|
+
const promptChars = (options.promptMeta && options.promptMeta.chars)
|
|
46
|
+
|| (options.prompt ? options.prompt.length : 0);
|
|
47
|
+
const maxCost = options.maxCost !== null && options.maxCost !== undefined ? options.maxCost : cfg.maxCost;
|
|
48
|
+
|
|
49
|
+
const budget = checkBudget(okLegs, { maxCostPerMtok, maxCost, promptChars });
|
|
50
|
+
if (!budget.ok) {
|
|
51
|
+
return { ok: false, message: 'Error: budget gate refused the wave', hint: formatBudgetError(budget) };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Claim AFTER the hard per-$/Mtok threshold has passed: a wave that is going
|
|
55
|
+
// to be refused for an over-priced model must not consume allowance on its
|
|
56
|
+
// way out and starve a sibling that would have fit.
|
|
57
|
+
const estimate = budget.breakdown.totalEstCost;
|
|
58
|
+
if (typeof options.reserveBudget === 'function' && !options.reserveBudget(estimate)) {
|
|
59
|
+
return {
|
|
60
|
+
ok: false,
|
|
61
|
+
message: 'Error: budget gate refused the wave',
|
|
62
|
+
hint: `Budget gate: estimated total $${estimate.toFixed(4)} does not fit the --max-cost `
|
|
63
|
+
+ 'allowance still unclaimed by concurrently launching waves (estimate, not guaranteed).\n'
|
|
64
|
+
+ 'The run continues with the waves that did launch. Override: --max-cost <$> to raise the '
|
|
65
|
+
+ 'ceiling, or --no-cost-gate to disable both guards.',
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
return { ok: true, estimate };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
module.exports = { preflightBudget };
|
|
@@ -48,35 +48,83 @@ function recordAttemptSpend({ doc, leg, currentModel, legId, waveId, project, at
|
|
|
48
48
|
} catch { /* best-effort */ }
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
/** Add up a list of token blocks, key by key. */
|
|
52
|
+
function foldTokens(blocks) {
|
|
53
|
+
const tokens = {};
|
|
54
|
+
for (const t of blocks) {
|
|
55
|
+
for (const [k, v] of Object.entries(t || {})) { tokens[k] = (tokens[k] || 0) + (v || 0); }
|
|
56
|
+
}
|
|
57
|
+
return tokens;
|
|
58
|
+
}
|
|
59
|
+
|
|
51
60
|
/**
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
* cost never reads as authoritative.
|
|
61
|
+
* Add up a list of resolved cost objects, tagging source 'mixed' when they
|
|
62
|
+
* differ — matching formatCost's `~` behavior so a summed cost never reads as
|
|
63
|
+
* authoritative. `none` is returned when not one of them carried a number,
|
|
64
|
+
* because a sum of nothing is not $0.
|
|
57
65
|
*/
|
|
58
|
-
function
|
|
59
|
-
const withUsage = (attempts || []).filter(a => a.usage && a.usage.tokens);
|
|
60
|
-
if (withUsage.length === 0) { return null; }
|
|
61
|
-
if (withUsage.length === 1) { return withUsage[0].usage; }
|
|
62
|
-
const tokens = {};
|
|
66
|
+
function foldCosts(costs, none) {
|
|
63
67
|
let amount = 0;
|
|
64
68
|
let anyCost = false;
|
|
65
69
|
const sources = new Set();
|
|
66
|
-
for (const
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
}
|
|
70
|
-
if (a.usage.cost && typeof a.usage.cost.amount === 'number') {
|
|
71
|
-
amount += a.usage.cost.amount;
|
|
70
|
+
for (const c of costs) {
|
|
71
|
+
if (c && typeof c.amount === 'number') {
|
|
72
|
+
amount += c.amount;
|
|
72
73
|
anyCost = true;
|
|
73
|
-
if (
|
|
74
|
+
if (c.source) { sources.add(c.source); }
|
|
74
75
|
}
|
|
75
76
|
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
:
|
|
79
|
-
|
|
77
|
+
if (!anyCost) { return none; }
|
|
78
|
+
return { amount, currency: 'USD',
|
|
79
|
+
source: sources.size > 1 ? 'mixed' : (sources.values().next().value || 'reported') };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Fold a leg's attempts[] into ONE usage block. A single-attempt leg returns that
|
|
84
|
+
* attempt's usage verbatim (no behavior change for non-fallback legs).
|
|
85
|
+
*
|
|
86
|
+
* ⚠️ v4.4.1 A1. This used to `return { tokens, cost }` — silently DISCARDING every
|
|
87
|
+
* other key resolveUsage puts on a usage block. The casualty was `subtreeUnknown`:
|
|
88
|
+
* a leg that fell back to a substitute AND left an unattributable subagent subtree
|
|
89
|
+
* lost the flag here, `sumWaveUsage` never counted it in `subtreeUnknownLegs`, and
|
|
90
|
+
* run.json reported `costExact: true` for a total that was not — precisely the lie
|
|
91
|
+
* `costExact` exists to prevent, and reachable only on the fallback path. So the
|
|
92
|
+
* fold now starts from a merge of every attempt's usage and overwrites only the
|
|
93
|
+
* keys it has a real opinion about; anything added to the block later survives by
|
|
94
|
+
* default instead of being dropped by omission.
|
|
95
|
+
*
|
|
96
|
+
* HOW EACH KIND OF KEY FOLDS, and why:
|
|
97
|
+
* - `tokens` / `cost` — SUMMED. They are per-attempt measurements of one leg's
|
|
98
|
+
* total consumption; every attempt really was billed.
|
|
99
|
+
* - `subtreeUnknown` — OR'd. It is a claim about EXACTNESS, not a quantity: if
|
|
100
|
+
* even one attempt left a subtree it could not account for, the leg's total is
|
|
101
|
+
* a floor, and that stays true no matter how exact the other attempts were.
|
|
102
|
+
* Any other fold (last-wins, or requiring every attempt to agree) would let a
|
|
103
|
+
* later clean attempt erase an earlier attempt's admitted gap.
|
|
104
|
+
* - `subtree` — SUMMED, not last-wins. Two attempts can each have walked and
|
|
105
|
+
* PRICED child sessions, and both spent real money; keeping only the last one's
|
|
106
|
+
* measurement would re-open the same under-report one level down, which
|
|
107
|
+
* sumWaveUsage's CA-1 docblock explicitly refuses to make.
|
|
108
|
+
* - anything else — last attempt wins, which is what the merge already does.
|
|
109
|
+
*/
|
|
110
|
+
function sumAttemptUsage(attempts) {
|
|
111
|
+
const withUsage = (attempts || []).filter(a => a.usage && a.usage.tokens);
|
|
112
|
+
if (withUsage.length === 0) { return null; }
|
|
113
|
+
if (withUsage.length === 1) { return withUsage[0].usage; }
|
|
114
|
+
const usages = withUsage.map(a => a.usage);
|
|
115
|
+
const out = Object.assign({}, ...usages);
|
|
116
|
+
out.tokens = foldTokens(usages.map(u => u.tokens));
|
|
117
|
+
out.cost = foldCosts(usages.map(u => u.cost), null);
|
|
118
|
+
const subtrees = usages.map(u => u.subtree).filter(Boolean);
|
|
119
|
+
if (subtrees.length > 0) {
|
|
120
|
+
out.subtree = {
|
|
121
|
+
sessions: subtrees.reduce((n, s) => n + (s.sessions || 0), 0),
|
|
122
|
+
tokens: foldTokens(subtrees.map(s => s.tokens)),
|
|
123
|
+
cost: foldCosts(subtrees.map(s => s.cost), { amount: null, currency: 'USD', source: 'unknown' }),
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
if (usages.some(u => u.subtreeUnknown)) { out.subtreeUnknown = true; }
|
|
127
|
+
return out;
|
|
80
128
|
}
|
|
81
129
|
|
|
82
130
|
/**
|
|
@@ -132,7 +132,25 @@ async function runSingleAttempt({ leg, legId, waveId, project, directory, follow
|
|
|
132
132
|
const status = legStatusFromResult(result);
|
|
133
133
|
const summary = result.summary || null;
|
|
134
134
|
const { resolveUsage } = require('../utils/pricing');
|
|
135
|
-
|
|
135
|
+
// v4.4 Task 2 (B4) + v4.4.1 CA-1: a leg that made a SUBAGENT (`task`) call has
|
|
136
|
+
// spend in a child OpenCode session that is billed separately and is NOT
|
|
137
|
+
// rolled into the parent session's cost. runHeadless now WALKS those sessions
|
|
138
|
+
// (src/sidecar/child-sessions.js), so `subtreeUnknown` narrows from "this leg
|
|
139
|
+
// called `task`, therefore assume the worst" to what it should always have
|
|
140
|
+
// meant: the walk could not account for the subtree.
|
|
141
|
+
//
|
|
142
|
+
// The walk is authoritative when it ran — including in the direction that
|
|
143
|
+
// CLEARS the flag, which the name-string proxy could never do (backlog CA-5:
|
|
144
|
+
// a tool merely NAMED `task` that spawns nothing used to make an exact run
|
|
145
|
+
// report itself inexact). The proxy survives only as the fallback for a leg
|
|
146
|
+
// where the walk could not run at all.
|
|
147
|
+
const walked = result && result.subtree;
|
|
148
|
+
const subtreeUnknown = walked
|
|
149
|
+
? !!walked.unknown
|
|
150
|
+
: !!(result && result.subagentToolCalls > 0);
|
|
151
|
+
const usage = result && result.usage
|
|
152
|
+
? resolveUsage({ model: leg.model, usageTotals: result.usage, subtreeUnknown, subtree: walked || undefined })
|
|
153
|
+
: null;
|
|
136
154
|
// If setup threw before the session dir existed, there is nothing on disk to
|
|
137
155
|
// finalize — still resolve to an error run document so the wave aggregates.
|
|
138
156
|
const legPatch = {
|
|
@@ -140,6 +158,16 @@ async function runSingleAttempt({ leg, legId, waveId, project, directory, follow
|
|
|
140
158
|
reason: result.error || undefined,
|
|
141
159
|
completedAt: new Date().toISOString(),
|
|
142
160
|
usage: usage || undefined,
|
|
161
|
+
// v4.4 B4 part 1: the leg completed with tool calls still live, so its
|
|
162
|
+
// OpenCode session may have kept working (and billing) afterwards. Travels
|
|
163
|
+
// with the leg so it is readable long after the run's stderr is gone.
|
|
164
|
+
toolSettleTimedOut: (result && result.toolSettleTimedOut) || undefined,
|
|
165
|
+
// v4.4.1 LC-2: whether the ceiling's abort landed. Deliberately NOT
|
|
166
|
+
// `|| undefined` like the flag above — a `false` here is the whole point
|
|
167
|
+
// ("we tried to stop it and could not; it may still be billing") and must
|
|
168
|
+
// survive onto disk. runHeadless sets it only when the ceiling was hit, so
|
|
169
|
+
// passing it through unchanged keeps a clean leg carrying neither field.
|
|
170
|
+
toolSettleAborted: result ? result.toolSettleAborted : undefined,
|
|
143
171
|
};
|
|
144
172
|
let finalMeta = legPatch;
|
|
145
173
|
if (legDir) {
|