dsh-plugin-soulbridge 0.0.112 → 0.0.113
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/README.md +15 -0
- package/index.js +175 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -45,6 +45,7 @@ dsh plugin --profile desktop add "github:Jason0826/dsh-plugin-soulbridge#v0.0.4"
|
|
|
45
45
|
| `user/message source=user chars=N` | 收到用户消息 |
|
|
46
46
|
| `soul ready: db=… soul=… user=…` | core 与数据库就绪 |
|
|
47
47
|
| `observe ok: chars=… written=… affect=… confidence=…` | **整条链路成功** |
|
|
48
|
+
| `turn cost: session=… turn=… requests=N (steps=… failed=… plugin=…)` | 这一轮的请求数到了 30(见下节) |
|
|
48
49
|
| `skipping profile bundle …` (stderr) | bundle 被启动器跳过,原因就在后面 |
|
|
49
50
|
|
|
50
51
|
## 卸载
|
|
@@ -94,6 +95,20 @@ DSH 启动器对 `package.json` 直接 `JSON.parse`,不剥 BOM。用 Windows P
|
|
|
94
95
|
为什么是"规则优先、模型补漏":实测规则精确率 1.00,但 26 条真实消息里漏了 19 条;
|
|
95
96
|
让模型覆盖规则没有收益,只在漏掉时补才有。宿主模型**只能在真实轮次内调用**,
|
|
96
97
|
启动时调用会被账号 provider 拒绝(见 `docs/experiment-2026-10-29-host-llm.md`)。
|
|
98
|
+
|
|
99
|
+
## 每轮花了多少(0026)
|
|
100
|
+
|
|
101
|
+
平台导出只有"每天一行",单轮花大钱看不出来。所以插件自己记:
|
|
102
|
+
|
|
103
|
+
| 落点 | 什么时候 | 内容 |
|
|
104
|
+
|---|---|---|
|
|
105
|
+
| `trace.jsonl` 的 `{"event":"turn-cost"}` | 每轮结束 | 请求数(= 步骤 + 失败尝试 + 插件自己的调用)、四类 token、耗时、结束原因 |
|
|
106
|
+
| `plugin.log` 的 `turn cost:` | 一轮 ≥ 30 次请求 | 同一份数字压成一行 |
|
|
107
|
+
|
|
108
|
+
计数来源是宿主的事件流:`step/start`(一次调用)、`assistant/attempt`(重试且没出消息)、
|
|
109
|
+
`assistant/message.usage`(token)。子代理跑在自己的会话里,因此各自一条记录——事件流
|
|
110
|
+
里没有父子关系,猜一个比写两条更坏。插件**不算钱**:金额只从 `scripts/billing.mjs`
|
|
111
|
+
(读平台导出)来,价目有峰谷两档且会变。
|
|
97
112
|
## 升级提示:新增工具需要新开会话
|
|
98
113
|
|
|
99
114
|
`ctx.inject(['tools'])` 注册的工具在**会话创建时**进入该会话的工具集。插件升级后
|
package/index.js
CHANGED
|
@@ -88,6 +88,151 @@ function trace(record) {
|
|
|
88
88
|
}
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
+
/** A turn that reaches this many model requests gets one line in plugin.log (0026). */
|
|
92
|
+
const TURN_REQUEST_ALERT = 30;
|
|
93
|
+
/** Subagent sessions each open a turn; past this many, the oldest unfinished turn is written out. */
|
|
94
|
+
const TURN_TRACKER_MAX = 64;
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* What one turn cost, in requests and tokens (brief 0026).
|
|
98
|
+
*
|
|
99
|
+
* The platform export is one line per day, so the turn that actually burned the
|
|
100
|
+
* money is invisible until the day is over. The host's event stream already
|
|
101
|
+
* carries everything needed to attribute it:
|
|
102
|
+
*
|
|
103
|
+
* step/start one model call was scheduled
|
|
104
|
+
* assistant/attempt a call that was retried and produced no message
|
|
105
|
+
* assistant/message a finished call, with the usage it reported
|
|
106
|
+
* turn/start, turn/end the window
|
|
107
|
+
*
|
|
108
|
+
* A subagent runs in its own session and therefore gets its own record; the
|
|
109
|
+
* event stream has no parent link, and guessing one would be worse than two
|
|
110
|
+
* honest lines. Money is deliberately absent — prices have peak/off-peak tiers
|
|
111
|
+
* and change, so amounts come only from `scripts/billing.mjs` in core.
|
|
112
|
+
*
|
|
113
|
+
* @param {object} [options]
|
|
114
|
+
* @param {number} [options.threshold] request count that triggers the log line.
|
|
115
|
+
* @param {number} [options.maxTurns] unfinished turns kept before the oldest is written out.
|
|
116
|
+
* @param {(record: object) => void} [options.onReport] called once per finished turn.
|
|
117
|
+
* @param {(line: string) => void} [options.onAlert] called when a turn reaches the threshold.
|
|
118
|
+
*/
|
|
119
|
+
export function createTurnCostTracker({ threshold = TURN_REQUEST_ALERT, maxTurns = TURN_TRACKER_MAX, onReport, onAlert } = {}) {
|
|
120
|
+
const open = new Map();
|
|
121
|
+
let lastSession;
|
|
122
|
+
|
|
123
|
+
const addUsage = (entry, usage) => {
|
|
124
|
+
if (!usage) return;
|
|
125
|
+
entry.input += Number(usage.inputTokens) || 0;
|
|
126
|
+
entry.cacheHit += Number(usage.cacheReadTokens) || 0;
|
|
127
|
+
entry.cacheWrite += Number(usage.cacheWriteTokens) || 0;
|
|
128
|
+
entry.output += Number(usage.outputTokens) || 0;
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
const requestsOf = (entry) => entry.steps + entry.failed + entry.plugin;
|
|
132
|
+
|
|
133
|
+
/** Write the record once, then forget the turn. `complete` is false for interrupted/evicted turns. */
|
|
134
|
+
const finish = (session, entry, reason, endedAt, complete) => {
|
|
135
|
+
open.delete(session);
|
|
136
|
+
const requests = requestsOf(entry);
|
|
137
|
+
if (requests === 0) return;
|
|
138
|
+
const secs = Math.max(0, Math.round((endedAt - entry.startedAt) / 1000));
|
|
139
|
+
const record = {
|
|
140
|
+
event: 'turn-cost',
|
|
141
|
+
session,
|
|
142
|
+
turn: entry.turn,
|
|
143
|
+
requests,
|
|
144
|
+
steps: entry.steps,
|
|
145
|
+
failed: entry.failed,
|
|
146
|
+
plugin: entry.plugin,
|
|
147
|
+
input: entry.input,
|
|
148
|
+
cacheHit: entry.cacheHit,
|
|
149
|
+
cacheWrite: entry.cacheWrite,
|
|
150
|
+
output: entry.output,
|
|
151
|
+
secs,
|
|
152
|
+
...(complete ? {} : { open: true }),
|
|
153
|
+
...(reason === undefined ? {} : { reason }),
|
|
154
|
+
};
|
|
155
|
+
onReport?.(record);
|
|
156
|
+
if (requests >= threshold) {
|
|
157
|
+
onAlert?.(
|
|
158
|
+
`turn cost: session=${session} turn=${entry.turn} requests=${requests}` +
|
|
159
|
+
` (steps=${entry.steps} failed=${entry.failed} plugin=${entry.plugin})` +
|
|
160
|
+
` input=${entry.input} cacheHit=${entry.cacheHit} output=${entry.output}` +
|
|
161
|
+
` secs=${secs}${complete ? '' : ' open'}`,
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
return {
|
|
167
|
+
/**
|
|
168
|
+
* Feed one session event. Events of a session whose turn was never opened are
|
|
169
|
+
* ignored rather than attached to a neighbour: a wrong attribution is worse
|
|
170
|
+
* than a missing one.
|
|
171
|
+
* @param {string} session - session id the event belongs to.
|
|
172
|
+
* @param {{type?: string, time?: number, data?: object}} event - host session event.
|
|
173
|
+
*/
|
|
174
|
+
observe(session, event) {
|
|
175
|
+
const type = event?.type;
|
|
176
|
+
const data = event?.data ?? {};
|
|
177
|
+
if (typeof data.turn !== 'number') return;
|
|
178
|
+
lastSession = session;
|
|
179
|
+
const entry = open.get(session);
|
|
180
|
+
if (type === 'turn/start') {
|
|
181
|
+
if (entry) finish(session, entry, 'restarted', event?.time ?? Date.now(), false);
|
|
182
|
+
open.set(session, {
|
|
183
|
+
turn: data.turn,
|
|
184
|
+
startedAt: event?.time ?? Date.now(),
|
|
185
|
+
steps: 0,
|
|
186
|
+
failed: 0,
|
|
187
|
+
plugin: 0,
|
|
188
|
+
input: 0,
|
|
189
|
+
cacheHit: 0,
|
|
190
|
+
cacheWrite: 0,
|
|
191
|
+
output: 0,
|
|
192
|
+
});
|
|
193
|
+
if (open.size > maxTurns) {
|
|
194
|
+
const oldest = open.keys().next().value;
|
|
195
|
+
finish(oldest, open.get(oldest), 'evicted', Date.now(), false);
|
|
196
|
+
}
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
if (!entry || entry.turn !== data.turn) return;
|
|
200
|
+
if (type === 'step/start') entry.steps += 1;
|
|
201
|
+
else if (type === 'assistant/attempt') entry.failed += 1;
|
|
202
|
+
else if (type === 'assistant/message') addUsage(entry, data.usage);
|
|
203
|
+
else if (type === 'turn/end') {
|
|
204
|
+
finish(session, entry, data.reason?.kind, event?.time ?? Date.now(), true);
|
|
205
|
+
}
|
|
206
|
+
},
|
|
207
|
+
/**
|
|
208
|
+
* Count one model call made by this plugin, attributed to the turn it ran
|
|
209
|
+
* inside. The turn is the most recent one the host reported an event for —
|
|
210
|
+
* the stage chain only runs while that turn is live.
|
|
211
|
+
*/
|
|
212
|
+
pluginCall() {
|
|
213
|
+
if (lastSession === undefined) return;
|
|
214
|
+
const entry = open.get(lastSession);
|
|
215
|
+
if (!entry) return;
|
|
216
|
+
entry.plugin += 1;
|
|
217
|
+
},
|
|
218
|
+
/**
|
|
219
|
+
* Add the tokens a plugin call reported to the same turn. Separate from
|
|
220
|
+
* {@link pluginCall} because the request is counted when it is dispatched
|
|
221
|
+
* (a failed call still costs money) while its usage only arrives at the end.
|
|
222
|
+
* @param {{inputTokens?: number, cacheReadTokens?: number, cacheWriteTokens?: number, outputTokens?: number}} [usage]
|
|
223
|
+
* usage the call reported; absent when the call failed before reporting.
|
|
224
|
+
*/
|
|
225
|
+
pluginUsage(usage) {
|
|
226
|
+
if (lastSession === undefined) return;
|
|
227
|
+
const entry = open.get(lastSession);
|
|
228
|
+
if (!entry) return;
|
|
229
|
+
addUsage(entry, usage);
|
|
230
|
+
},
|
|
231
|
+
/** Unfinished turns currently held (observability for the bound in tests). */
|
|
232
|
+
openCount: () => open.size,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
91
236
|
/** Prompt assembly can run several times per step; do not hit SQLite every time. */
|
|
92
237
|
|
|
93
238
|
/**
|
|
@@ -134,6 +279,8 @@ function readConfig() {
|
|
|
134
279
|
/** Adapter side of the core LlmFn port: the host model, called inside a turn. */
|
|
135
280
|
let currentTurn;
|
|
136
281
|
let currentStep;
|
|
282
|
+
/** Per-turn cost accounting (0026); null until apply() attaches it (tests import this module bare). */
|
|
283
|
+
let turnCostTracker;
|
|
137
284
|
|
|
138
285
|
// Measured, not guessed: 279 calls, p50=1.7s p90=6.8s p99=46.7s, max=142.9s.
|
|
139
286
|
// 90s is ~2x p99 and caps a hung call without cutting the ordinary tail.
|
|
@@ -170,6 +317,11 @@ export function makeLlm(ctx, config) {
|
|
|
170
317
|
// a call actually lands in.
|
|
171
318
|
const started = Date.now();
|
|
172
319
|
log(`llm probe: start turn=${currentTurn ?? '?'} step=${currentStep ?? '?'} chars=${prompt.length}`);
|
|
320
|
+
// This call is a model request too (0026): count it, and keep the usage the
|
|
321
|
+
// stream reports so the turn's token total is complete rather than just its
|
|
322
|
+
// agent-loop part.
|
|
323
|
+
let usage;
|
|
324
|
+
turnCostTracker?.pluginCall();
|
|
173
325
|
try {
|
|
174
326
|
const text = await withTimeout(
|
|
175
327
|
collectText(
|
|
@@ -185,10 +337,14 @@ export function makeLlm(ctx, config) {
|
|
|
185
337
|
},
|
|
186
338
|
],
|
|
187
339
|
}),
|
|
340
|
+
(reported) => {
|
|
341
|
+
usage = reported;
|
|
342
|
+
},
|
|
188
343
|
),
|
|
189
344
|
LLM_TIMEOUT_MS,
|
|
190
345
|
'llm call',
|
|
191
346
|
);
|
|
347
|
+
turnCostTracker?.pluginUsage(usage);
|
|
192
348
|
log(
|
|
193
349
|
`llm probe: end ok ms=${Date.now() - started} out=${typeof text === 'string' ? text.length : -1}` +
|
|
194
350
|
` step=${currentStep ?? '?'}`,
|
|
@@ -215,8 +371,12 @@ export function makeLlm(ctx, config) {
|
|
|
215
371
|
* `finish{reason:{kind:'error'|'aborted'}}` instead of throwing, so without this
|
|
216
372
|
* check the failure came back as `''` and the caller read it as "the model said
|
|
217
373
|
* nothing". `collectText` throws unless some text was produced before the failure.
|
|
374
|
+
*
|
|
375
|
+
* @param {AsyncIterable<object>} stream - chunks of one model call.
|
|
376
|
+
* @param {(usage: object) => void} [onUsage] called with the usage chunk, if the
|
|
377
|
+
* adapter reported one; without it a call's tokens have no reader (0026).
|
|
218
378
|
*/
|
|
219
|
-
export async function collectText(stream) {
|
|
379
|
+
export async function collectText(stream, onUsage) {
|
|
220
380
|
let text = '';
|
|
221
381
|
let sawDelta = false;
|
|
222
382
|
let failure;
|
|
@@ -226,6 +386,8 @@ export async function collectText(stream) {
|
|
|
226
386
|
sawDelta = true;
|
|
227
387
|
} else if (!sawDelta && chunk.type === 'block-end' && chunk.block?.type === 'text') {
|
|
228
388
|
text += chunk.block.text ?? '';
|
|
389
|
+
} else if (chunk.type === 'usage' && chunk.usage) {
|
|
390
|
+
onUsage?.(chunk.usage);
|
|
229
391
|
} else if (chunk.type === 'finish' && (chunk.reason?.kind === 'error' || chunk.reason?.kind === 'aborted')) {
|
|
230
392
|
failure = chunk.reason;
|
|
231
393
|
}
|
|
@@ -2280,6 +2442,14 @@ export function apply(ctx) {
|
|
|
2280
2442
|
// Backfill is triggered by the first user message, not here: at apply() time
|
|
2281
2443
|
// the host model service is not mounted yet and every call fails.
|
|
2282
2444
|
|
|
2445
|
+
// Per-turn cost (0026): a turn's record goes to trace.jsonl, and a turn that
|
|
2446
|
+
// reaches the threshold also gets one line in plugin.log. Attached before the
|
|
2447
|
+
// event listener so no turn can be missed.
|
|
2448
|
+
turnCostTracker = createTurnCostTracker({
|
|
2449
|
+
onReport: (record) => trace(record),
|
|
2450
|
+
onAlert: (line) => log(line),
|
|
2451
|
+
});
|
|
2452
|
+
|
|
2283
2453
|
try {
|
|
2284
2454
|
ctx.effect(
|
|
2285
2455
|
() => {
|
|
@@ -2366,6 +2536,10 @@ export function apply(ctx) {
|
|
|
2366
2536
|
|
|
2367
2537
|
const sessionId = session?.id ?? 'default';
|
|
2368
2538
|
|
|
2539
|
+
// Before any early return below: turn/end is where the cost record is
|
|
2540
|
+
// written, and that branch returns.
|
|
2541
|
+
turnCostTracker?.observe(sessionId, event);
|
|
2542
|
+
|
|
2369
2543
|
// Keep the last reply: a correction only makes sense next to what it targets.
|
|
2370
2544
|
if (typeof event.data?.turn === 'number') currentTurn = event.data.turn;
|
|
2371
2545
|
if (typeof event.data?.step === 'number') currentStep = event.data.step;
|