u-foo 2.5.10 → 2.5.12
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/package.json +1 -1
- package/src/agents/controller/ufooAgent.js +27 -1
- package/src/agents/internal/internalRunner.js +44 -6
- package/src/agents/providers/upstreamTransport.js +31 -1
- package/src/app/chat/dashboardView.js +7 -1
- package/src/code/nativeRunner.js +169 -2
- package/src/code/repl.js +29 -0
- package/src/code/usageStore.js +101 -0
- package/src/ui/ink/ChatApp.js +25 -2
- package/src/ui/ink/DashboardBar.js +7 -1
package/package.json
CHANGED
|
@@ -640,6 +640,31 @@ function stripMarkdownFence(text = "") {
|
|
|
640
640
|
return raw;
|
|
641
641
|
}
|
|
642
642
|
|
|
643
|
+
function toNonNegativeInt(value) {
|
|
644
|
+
const num = Number(value);
|
|
645
|
+
if (!Number.isFinite(num) || num < 0) return 0;
|
|
646
|
+
return Math.floor(num);
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// Maps provider usage payloads (OpenAI chat, Anthropic messages, Codex responses)
|
|
650
|
+
// onto the metric fields read by extractModelMetrics in loopRuntime.js.
|
|
651
|
+
function extractUsageTokens(usage = null) {
|
|
652
|
+
const item = usage && typeof usage === "object" ? usage : {};
|
|
653
|
+
return {
|
|
654
|
+
input_tokens: toNonNegativeInt(item.input_tokens || item.prompt_tokens),
|
|
655
|
+
output_tokens: toNonNegativeInt(item.output_tokens || item.completion_tokens),
|
|
656
|
+
cache_read_tokens: toNonNegativeInt(
|
|
657
|
+
item.cache_read_tokens
|
|
658
|
+
|| item.cache_read_input_tokens
|
|
659
|
+
|| item.cached_input_tokens
|
|
660
|
+
|| (item.input_tokens_details && item.input_tokens_details.cached_tokens)
|
|
661
|
+
),
|
|
662
|
+
cache_creation_tokens: toNonNegativeInt(
|
|
663
|
+
item.cache_creation_tokens || item.cache_creation_input_tokens
|
|
664
|
+
),
|
|
665
|
+
};
|
|
666
|
+
}
|
|
667
|
+
|
|
643
668
|
async function runNativeRouterCall({
|
|
644
669
|
projectRoot,
|
|
645
670
|
prompt,
|
|
@@ -708,7 +733,7 @@ async function runUfooAgent({
|
|
|
708
733
|
console.error(`[ufoo-agent] native provider failed: ${res.error || "unknown error"}`);
|
|
709
734
|
return { ok: false, error: res.error };
|
|
710
735
|
} else {
|
|
711
|
-
res = { ok: true, output: res.output, sessionId: "", provider: res.provider, model: res.model };
|
|
736
|
+
res = { ok: true, output: res.output, sessionId: "", provider: res.provider, model: res.model, usage: res.usage };
|
|
712
737
|
}
|
|
713
738
|
}
|
|
714
739
|
|
|
@@ -746,6 +771,7 @@ async function runUfooAgent({
|
|
|
746
771
|
ok: true,
|
|
747
772
|
payload,
|
|
748
773
|
meta: {
|
|
774
|
+
...extractUsageTokens(res.usage),
|
|
749
775
|
memory_prefix_tokens: memoryPrefixResult.estimated_tokens || 0,
|
|
750
776
|
cache_semistatic_hit: memoryPrefixResult.cache_semistatic_hit || 0,
|
|
751
777
|
cache_semistatic_miss: memoryPrefixResult.cache_semistatic_miss || 0,
|
|
@@ -327,7 +327,7 @@ async function handleEvent(
|
|
|
327
327
|
};
|
|
328
328
|
|
|
329
329
|
if (threadRuntime && threadRuntime.enabled && threadRuntime.thread) {
|
|
330
|
-
|
|
330
|
+
return handleThreadedEvent({
|
|
331
331
|
agentType,
|
|
332
332
|
provider,
|
|
333
333
|
publisher,
|
|
@@ -338,7 +338,6 @@ async function handleEvent(
|
|
|
338
338
|
threadRuntime,
|
|
339
339
|
tracker,
|
|
340
340
|
});
|
|
341
|
-
return;
|
|
342
341
|
}
|
|
343
342
|
|
|
344
343
|
const errorText = `[internal:${agentType}] error: no thread runtime available for provider ${provider}; cliRunner fallback has been removed`;
|
|
@@ -374,6 +373,31 @@ function summarizeThreadToolCall(event = {}) {
|
|
|
374
373
|
return [name, compactToolDetail(detail)].filter(Boolean).join(" · ");
|
|
375
374
|
}
|
|
376
375
|
|
|
376
|
+
function toNonNegativeInt(value) {
|
|
377
|
+
const num = Number(value);
|
|
378
|
+
if (!Number.isFinite(num) || num < 0) return 0;
|
|
379
|
+
return Math.floor(num);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// Normalizes provider usage payloads (claude/codex thread events) onto the
|
|
383
|
+
// metric fields read by extractModelMetrics in agents/controller/loopRuntime.js.
|
|
384
|
+
function normalizeTurnUsage(usage = null) {
|
|
385
|
+
const item = usage && typeof usage === "object" ? usage : {};
|
|
386
|
+
return {
|
|
387
|
+
input_tokens: toNonNegativeInt(item.input_tokens || item.prompt_tokens),
|
|
388
|
+
output_tokens: toNonNegativeInt(item.output_tokens || item.completion_tokens),
|
|
389
|
+
cache_read_tokens: toNonNegativeInt(
|
|
390
|
+
item.cache_read_tokens
|
|
391
|
+
|| item.cache_read_input_tokens
|
|
392
|
+
|| item.cached_input_tokens
|
|
393
|
+
|| (item.input_tokens_details && item.input_tokens_details.cached_tokens)
|
|
394
|
+
),
|
|
395
|
+
cache_creation_tokens: toNonNegativeInt(
|
|
396
|
+
item.cache_creation_tokens || item.cache_creation_input_tokens
|
|
397
|
+
),
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
|
|
377
401
|
async function handleThreadedEvent({
|
|
378
402
|
agentType,
|
|
379
403
|
provider,
|
|
@@ -387,6 +411,8 @@ async function handleThreadedEvent({
|
|
|
387
411
|
}) {
|
|
388
412
|
try {
|
|
389
413
|
const plainReplyParts = [];
|
|
414
|
+
let turnUsage = null;
|
|
415
|
+
let stopReason = "";
|
|
390
416
|
if (tracker && typeof tracker.notifyTurnStart === "function") {
|
|
391
417
|
tracker.notifyTurnStart();
|
|
392
418
|
}
|
|
@@ -409,6 +435,11 @@ async function handleThreadedEvent({
|
|
|
409
435
|
if (streamToPublisher && summary) {
|
|
410
436
|
emitStreamDelta(`\nTool: ${summary}\n`);
|
|
411
437
|
}
|
|
438
|
+
} else if (event.type === "usage" && event.usage) {
|
|
439
|
+
turnUsage = normalizeTurnUsage(event.usage);
|
|
440
|
+
} else if (event.type === "turn_completed") {
|
|
441
|
+
if (event.usage) turnUsage = normalizeTurnUsage(event.usage);
|
|
442
|
+
if (event.stopReason) stopReason = String(event.stopReason);
|
|
412
443
|
} else if (event.type === "turn_failed") {
|
|
413
444
|
throw new Error(event.error || `thread turn failed for ${agentType}`);
|
|
414
445
|
}
|
|
@@ -418,15 +449,21 @@ async function handleThreadedEvent({
|
|
|
418
449
|
}
|
|
419
450
|
|
|
420
451
|
if (streamToPublisher) {
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
);
|
|
452
|
+
const doneEnvelope = { stream: true, done: true, reason: "complete" };
|
|
453
|
+
if (turnUsage) doneEnvelope.usage = turnUsage;
|
|
454
|
+
busSender.enqueue(publisher, JSON.stringify(doneEnvelope));
|
|
425
455
|
} else {
|
|
426
456
|
const reply = plainReplyParts.join("").trim();
|
|
427
457
|
if (reply) busSender.enqueue(publisher, reply);
|
|
428
458
|
}
|
|
429
459
|
await busSender.flush();
|
|
460
|
+
return {
|
|
461
|
+
ok: true,
|
|
462
|
+
meta: {
|
|
463
|
+
...(turnUsage || normalizeTurnUsage(null)),
|
|
464
|
+
stop_reason: stopReason,
|
|
465
|
+
},
|
|
466
|
+
};
|
|
430
467
|
} catch (err) {
|
|
431
468
|
if (threadRuntime && typeof threadRuntime.rebuildThread === "function") {
|
|
432
469
|
await threadRuntime.rebuildThread();
|
|
@@ -450,6 +487,7 @@ async function handleThreadedEvent({
|
|
|
450
487
|
busSender.enqueue(publisher, errorText);
|
|
451
488
|
}
|
|
452
489
|
await busSender.flush();
|
|
490
|
+
return { ok: false, error: errorText };
|
|
453
491
|
}
|
|
454
492
|
}
|
|
455
493
|
|
|
@@ -69,6 +69,29 @@ function buildOpenAiChatRequest({
|
|
|
69
69
|
return request;
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
+
// Anthropic prompt caching allows up to 4 cache_control breakpoints; the
|
|
73
|
+
// system prompt is a stable prefix, so it is always marked, and once there
|
|
74
|
+
// is real history the last user message is marked too so follow-up turns
|
|
75
|
+
// reuse the cached conversation prefix.
|
|
76
|
+
const ANTHROPIC_CACHE_CONTROL = { type: "ephemeral" };
|
|
77
|
+
const ANTHROPIC_CACHE_MIN_HISTORY = 3;
|
|
78
|
+
|
|
79
|
+
function withAnthropicCacheControl(content) {
|
|
80
|
+
if (Array.isArray(content)) {
|
|
81
|
+
if (!content.length) return content;
|
|
82
|
+
return content.map((block, index) => (
|
|
83
|
+
index === content.length - 1 && block && typeof block === "object"
|
|
84
|
+
? { ...block, cache_control: { ...ANTHROPIC_CACHE_CONTROL } }
|
|
85
|
+
: block
|
|
86
|
+
));
|
|
87
|
+
}
|
|
88
|
+
return [{
|
|
89
|
+
type: "text",
|
|
90
|
+
text: String(content || ""),
|
|
91
|
+
cache_control: { ...ANTHROPIC_CACHE_CONTROL },
|
|
92
|
+
}];
|
|
93
|
+
}
|
|
94
|
+
|
|
72
95
|
function buildAnthropicMessagesRequest({
|
|
73
96
|
model = "",
|
|
74
97
|
systemPrompt = "",
|
|
@@ -82,13 +105,20 @@ function buildAnthropicMessagesRequest({
|
|
|
82
105
|
if (!requestMessages.length) {
|
|
83
106
|
requestMessages.push({ role: "user", content: String(prompt || "") });
|
|
84
107
|
}
|
|
108
|
+
if (requestMessages.length >= ANTHROPIC_CACHE_MIN_HISTORY) {
|
|
109
|
+
for (let i = requestMessages.length - 1; i >= 0; i -= 1) {
|
|
110
|
+
if (requestMessages[i].role !== "user") continue;
|
|
111
|
+
requestMessages[i].content = withAnthropicCacheControl(requestMessages[i].content);
|
|
112
|
+
break;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
85
115
|
const request = {
|
|
86
116
|
model: String(model || "").trim(),
|
|
87
117
|
max_tokens: maxTokens,
|
|
88
118
|
messages: requestMessages,
|
|
89
119
|
temperature,
|
|
90
120
|
};
|
|
91
|
-
if (systemPrompt) request.system = systemPrompt;
|
|
121
|
+
if (systemPrompt) request.system = withAnthropicCacheControl(String(systemPrompt));
|
|
92
122
|
if (Array.isArray(tools) && tools.length > 0) {
|
|
93
123
|
request.tools = tools.slice();
|
|
94
124
|
}
|
|
@@ -72,6 +72,7 @@ function formatLoopSummary(loopSummary) {
|
|
|
72
72
|
const rounds = Number(loopSummary.rounds) || 0;
|
|
73
73
|
const toolCalls = Number(loopSummary.tool_calls) || 0;
|
|
74
74
|
const totalTokens = Number(loopSummary.total_tokens) || 0;
|
|
75
|
+
const inputTokens = Number(loopSummary.input_tokens) || 0;
|
|
75
76
|
const cacheReadTokens = Number(loopSummary.cache_read_tokens) || 0;
|
|
76
77
|
const cacheCreationTokens = Number(loopSummary.cache_creation_tokens) || 0;
|
|
77
78
|
const terminalReason = String(loopSummary.terminal_reason || "").trim();
|
|
@@ -87,7 +88,12 @@ function formatLoopSummary(loopSummary) {
|
|
|
87
88
|
`tok${totalTokens}`,
|
|
88
89
|
];
|
|
89
90
|
if (cacheReadTokens > 0 || cacheCreationTokens > 0) {
|
|
90
|
-
|
|
91
|
+
let cachePart = `cache${cacheReadTokens}/${cacheCreationTokens}`;
|
|
92
|
+
if (cacheReadTokens > 0) {
|
|
93
|
+
const hitRate = Math.round((cacheReadTokens / (cacheReadTokens + inputTokens)) * 100);
|
|
94
|
+
cachePart += `(${hitRate}%)`;
|
|
95
|
+
}
|
|
96
|
+
parts.push(cachePart);
|
|
91
97
|
}
|
|
92
98
|
if (toolDistribution) {
|
|
93
99
|
parts.push(toolDistribution);
|
package/src/code/nativeRunner.js
CHANGED
|
@@ -5,6 +5,7 @@ const {
|
|
|
5
5
|
resolveKimiUpstreamCredentials,
|
|
6
6
|
} = require("../agents/providers/credentials/kimi");
|
|
7
7
|
const { runToolCall } = require("./dispatch");
|
|
8
|
+
const { appendUsageRecord } = require("./usageStore");
|
|
8
9
|
const { getReadToolDescription } = require("../agents/prompts/native/toolDescriptions/read");
|
|
9
10
|
const { getWriteToolDescription } = require("../agents/prompts/native/toolDescriptions/write");
|
|
10
11
|
const { getEditToolDescription } = require("../agents/prompts/native/toolDescriptions/edit");
|
|
@@ -25,6 +26,11 @@ const DEFAULT_MAX_NATIVE_TOOL_ERRORS = 5;
|
|
|
25
26
|
// via UFOO_UCODE_MAX_TOKENS (positive integer).
|
|
26
27
|
const DEFAULT_OPENAI_MAX_TOKENS = 131072;
|
|
27
28
|
const DEFAULT_ANTHROPIC_MAX_TOKENS = 64000;
|
|
29
|
+
// Prompt caching is GA on the current Messages API: cache_control blocks need
|
|
30
|
+
// no anthropic-beta header. Kept as a constant so the marker shape stays in
|
|
31
|
+
// one place (system block + last history message, 2 of the 4 allowed
|
|
32
|
+
// breakpoints).
|
|
33
|
+
const ANTHROPIC_CACHE_CONTROL = Object.freeze({ type: "ephemeral" });
|
|
28
34
|
|
|
29
35
|
function nowMs() {
|
|
30
36
|
return Date.now();
|
|
@@ -53,6 +59,58 @@ function resolveMaxTokens(fallback) {
|
|
|
53
59
|
return normalizePositiveInt(process.env.UFOO_UCODE_MAX_TOKENS, fallback);
|
|
54
60
|
}
|
|
55
61
|
|
|
62
|
+
function toUsageInt(value) {
|
|
63
|
+
const parsed = Number(value);
|
|
64
|
+
if (!Number.isFinite(parsed) || parsed <= 0) return 0;
|
|
65
|
+
return Math.floor(parsed);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function createUsageTotals() {
|
|
69
|
+
return {
|
|
70
|
+
turns: 0,
|
|
71
|
+
input: 0,
|
|
72
|
+
output: 0,
|
|
73
|
+
cacheRead: 0,
|
|
74
|
+
cacheCreation: 0,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function addUsageTotals(totals, usage = null) {
|
|
79
|
+
if (!usage || typeof usage !== "object") return totals;
|
|
80
|
+
totals.input += toUsageInt(usage.input);
|
|
81
|
+
totals.output += toUsageInt(usage.output);
|
|
82
|
+
totals.cacheRead += toUsageInt(usage.cacheRead);
|
|
83
|
+
totals.cacheCreation += toUsageInt(usage.cacheCreation);
|
|
84
|
+
return totals;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// OpenAI-compatible streams end with one usage chunk carrying whole-turn
|
|
88
|
+
// totals (prompt_tokens_details.cached_tokens counts the cache hits).
|
|
89
|
+
function readOpenAiUsage(raw) {
|
|
90
|
+
if (!raw || typeof raw !== "object") return null;
|
|
91
|
+
const details = raw.prompt_tokens_details && typeof raw.prompt_tokens_details === "object"
|
|
92
|
+
? raw.prompt_tokens_details
|
|
93
|
+
: {};
|
|
94
|
+
return {
|
|
95
|
+
input: toUsageInt(raw.prompt_tokens),
|
|
96
|
+
output: toUsageInt(raw.completion_tokens),
|
|
97
|
+
cacheRead: toUsageInt(details.cached_tokens),
|
|
98
|
+
cacheCreation: 0,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Anthropic reports input/cache tokens once on message_start; output tokens
|
|
103
|
+
// arrive per message_delta. The non-streaming body carries the full totals.
|
|
104
|
+
function readAnthropicUsage(raw, { includeOutput = false } = {}) {
|
|
105
|
+
if (!raw || typeof raw !== "object") return null;
|
|
106
|
+
return {
|
|
107
|
+
input: toUsageInt(raw.input_tokens),
|
|
108
|
+
output: includeOutput ? toUsageInt(raw.output_tokens) : 0,
|
|
109
|
+
cacheRead: toUsageInt(raw.cache_read_input_tokens),
|
|
110
|
+
cacheCreation: toUsageInt(raw.cache_creation_input_tokens),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
56
114
|
function enforceNativeToolBudget({
|
|
57
115
|
toolCallsExecuted = 0,
|
|
58
116
|
toolErrors = 0,
|
|
@@ -576,6 +634,8 @@ async function runOpenAiLikeTurn({
|
|
|
576
634
|
tools: buildCoreToolSpecs(),
|
|
577
635
|
tool_choice: "auto",
|
|
578
636
|
stream: true,
|
|
637
|
+
// Ask for the terminal usage chunk so token/cache accounting works.
|
|
638
|
+
stream_options: { include_usage: true },
|
|
579
639
|
// Kimi k3 rejects any temperature other than 1.
|
|
580
640
|
temperature: normalizeProvider(provider) === "kimi" ? 1 : 0,
|
|
581
641
|
};
|
|
@@ -592,6 +652,7 @@ async function runOpenAiLikeTurn({
|
|
|
592
652
|
let responseText = "";
|
|
593
653
|
let nextSyntheticIndex = 0;
|
|
594
654
|
let lastSyntheticIndex = -1;
|
|
655
|
+
let streamUsage = null;
|
|
595
656
|
|
|
596
657
|
return runSseRequest({
|
|
597
658
|
url,
|
|
@@ -612,12 +673,18 @@ async function runOpenAiLikeTurn({
|
|
|
612
673
|
return {
|
|
613
674
|
text,
|
|
614
675
|
toolCalls,
|
|
676
|
+
usage: readOpenAiUsage(data && data.usage),
|
|
615
677
|
};
|
|
616
678
|
},
|
|
617
679
|
onEvent: ({ data }) => {
|
|
618
680
|
const chunk = parseJsonSafe(data, null);
|
|
619
681
|
if (!chunk || typeof chunk !== "object") return;
|
|
620
682
|
|
|
683
|
+
// The usage chunk carries empty choices, so read it before the
|
|
684
|
+
// choice guard below; latest wins (it reports whole-turn totals).
|
|
685
|
+
const chunkUsage = readOpenAiUsage(chunk.usage);
|
|
686
|
+
if (chunkUsage) streamUsage = chunkUsage;
|
|
687
|
+
|
|
621
688
|
const choice = chunk.choices && chunk.choices[0] ? chunk.choices[0] : null;
|
|
622
689
|
if (!choice || typeof choice !== "object") return;
|
|
623
690
|
|
|
@@ -695,6 +762,8 @@ async function runOpenAiLikeTurn({
|
|
|
695
762
|
const fallbackBlock = parseSseDataBlock(rawBuffer);
|
|
696
763
|
if (fallbackBlock && fallbackBlock !== "[DONE]") {
|
|
697
764
|
const chunk = parseJsonSafe(fallbackBlock, null);
|
|
765
|
+
const tailUsage = readOpenAiUsage(chunk && chunk.usage);
|
|
766
|
+
if (tailUsage) streamUsage = tailUsage;
|
|
698
767
|
const choice = chunk && chunk.choices && chunk.choices[0] ? chunk.choices[0] : null;
|
|
699
768
|
if (choice && choice.delta && typeof choice.delta.content === "string" && choice.delta.content) {
|
|
700
769
|
responseText += choice.delta.content;
|
|
@@ -709,6 +778,7 @@ async function runOpenAiLikeTurn({
|
|
|
709
778
|
toolCalls: Array.from(toolCallMap.entries())
|
|
710
779
|
.sort((a, b) => a[0] - b[0])
|
|
711
780
|
.map((entry) => entry[1]),
|
|
781
|
+
usage: streamUsage,
|
|
712
782
|
}),
|
|
713
783
|
});
|
|
714
784
|
}
|
|
@@ -751,6 +821,48 @@ function extractAnthropicToolCalls(content = []) {
|
|
|
751
821
|
}));
|
|
752
822
|
}
|
|
753
823
|
|
|
824
|
+
// Mark the newest message with a cache breakpoint so the append-only history
|
|
825
|
+
// prefix is served from the prompt cache. The payload gets a copy: stamping
|
|
826
|
+
// cache_control onto the shared history array would leave stale breakpoints
|
|
827
|
+
// behind as later turns append, eventually exceeding the 4-breakpoint limit.
|
|
828
|
+
function withAnthropicCacheBreakpoint(messages = []) {
|
|
829
|
+
if (!Array.isArray(messages) || messages.length === 0) return messages;
|
|
830
|
+
const copy = messages.slice();
|
|
831
|
+
const lastIndex = copy.length - 1;
|
|
832
|
+
const last = copy[lastIndex];
|
|
833
|
+
if (!last || typeof last !== "object" || Array.isArray(last)) return copy;
|
|
834
|
+
if (typeof last.content === "string") {
|
|
835
|
+
if (!last.content) return copy;
|
|
836
|
+
copy[lastIndex] = {
|
|
837
|
+
...last,
|
|
838
|
+
content: [
|
|
839
|
+
{
|
|
840
|
+
type: "text",
|
|
841
|
+
text: last.content,
|
|
842
|
+
cache_control: { ...ANTHROPIC_CACHE_CONTROL },
|
|
843
|
+
},
|
|
844
|
+
],
|
|
845
|
+
};
|
|
846
|
+
return copy;
|
|
847
|
+
}
|
|
848
|
+
if (Array.isArray(last.content) && last.content.length > 0) {
|
|
849
|
+
const blocks = last.content.slice();
|
|
850
|
+
const blockIndex = blocks.length - 1;
|
|
851
|
+
const block = blocks[blockIndex];
|
|
852
|
+
if (block && typeof block === "object" && !Array.isArray(block)) {
|
|
853
|
+
blocks[blockIndex] = {
|
|
854
|
+
...block,
|
|
855
|
+
cache_control: { ...ANTHROPIC_CACHE_CONTROL },
|
|
856
|
+
};
|
|
857
|
+
copy[lastIndex] = {
|
|
858
|
+
...last,
|
|
859
|
+
content: blocks,
|
|
860
|
+
};
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
return copy;
|
|
864
|
+
}
|
|
865
|
+
|
|
754
866
|
async function runAnthropicTurn({
|
|
755
867
|
url = "",
|
|
756
868
|
apiKey = "",
|
|
@@ -766,13 +878,21 @@ async function runAnthropicTurn({
|
|
|
766
878
|
const payload = {
|
|
767
879
|
model,
|
|
768
880
|
max_tokens: resolveMaxTokens(DEFAULT_ANTHROPIC_MAX_TOKENS),
|
|
769
|
-
messages,
|
|
881
|
+
messages: withAnthropicCacheBreakpoint(messages),
|
|
770
882
|
tools: buildAnthropicToolSpecs(),
|
|
771
883
|
stream: true,
|
|
772
884
|
};
|
|
773
885
|
const systemText = String(systemPrompt || "").trim();
|
|
774
886
|
if (systemText) {
|
|
775
|
-
|
|
887
|
+
// Block form with a cache breakpoint; the system prompt is the most
|
|
888
|
+
// stable prefix of every request.
|
|
889
|
+
payload.system = [
|
|
890
|
+
{
|
|
891
|
+
type: "text",
|
|
892
|
+
text: systemText,
|
|
893
|
+
cache_control: { ...ANTHROPIC_CACHE_CONTROL },
|
|
894
|
+
},
|
|
895
|
+
];
|
|
776
896
|
}
|
|
777
897
|
|
|
778
898
|
const headers = {
|
|
@@ -787,6 +907,7 @@ async function runAnthropicTurn({
|
|
|
787
907
|
let responseText = "";
|
|
788
908
|
let nextSyntheticBlockIndex = 0;
|
|
789
909
|
let lastBlockIndex = -1;
|
|
910
|
+
const turnUsage = { input: 0, output: 0, cacheRead: 0, cacheCreation: 0 };
|
|
790
911
|
|
|
791
912
|
return runSseRequest({
|
|
792
913
|
url,
|
|
@@ -804,10 +925,12 @@ async function runAnthropicTurn({
|
|
|
804
925
|
if (text && typeof onTextDelta === "function") {
|
|
805
926
|
onTextDelta(text);
|
|
806
927
|
}
|
|
928
|
+
addUsageTotals(turnUsage, readAnthropicUsage(data && data.usage, { includeOutput: true }));
|
|
807
929
|
return {
|
|
808
930
|
text,
|
|
809
931
|
assistantContent: content,
|
|
810
932
|
toolCalls: extractAnthropicToolCalls(content),
|
|
933
|
+
usage: turnUsage,
|
|
811
934
|
};
|
|
812
935
|
},
|
|
813
936
|
onEvent: ({ event, data }) => {
|
|
@@ -821,6 +944,28 @@ async function runAnthropicTurn({
|
|
|
821
944
|
throw new Error(errMsg);
|
|
822
945
|
}
|
|
823
946
|
|
|
947
|
+
if (event === "message_start") {
|
|
948
|
+
const messageUsage = readAnthropicUsage(
|
|
949
|
+
payloadChunk.message && typeof payloadChunk.message === "object"
|
|
950
|
+
? payloadChunk.message.usage
|
|
951
|
+
: null
|
|
952
|
+
);
|
|
953
|
+
if (messageUsage) {
|
|
954
|
+
turnUsage.input = messageUsage.input;
|
|
955
|
+
turnUsage.cacheRead = messageUsage.cacheRead;
|
|
956
|
+
turnUsage.cacheCreation = messageUsage.cacheCreation;
|
|
957
|
+
}
|
|
958
|
+
return;
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
if (event === "message_delta") {
|
|
962
|
+
const deltaUsage = payloadChunk.usage && typeof payloadChunk.usage === "object"
|
|
963
|
+
? payloadChunk.usage
|
|
964
|
+
: {};
|
|
965
|
+
turnUsage.output += toUsageInt(deltaUsage.output_tokens);
|
|
966
|
+
return;
|
|
967
|
+
}
|
|
968
|
+
|
|
824
969
|
if (event === "content_block_start") {
|
|
825
970
|
let index;
|
|
826
971
|
if (Number.isFinite(payloadChunk.index)) {
|
|
@@ -957,6 +1102,7 @@ async function runAnthropicTurn({
|
|
|
957
1102
|
text: responseText,
|
|
958
1103
|
assistantContent,
|
|
959
1104
|
toolCalls: extractAnthropicToolCalls(assistantContent),
|
|
1105
|
+
usage: turnUsage,
|
|
960
1106
|
};
|
|
961
1107
|
},
|
|
962
1108
|
});
|
|
@@ -1133,6 +1279,7 @@ async function runNativeLoop({
|
|
|
1133
1279
|
let toolCallsExecuted = 0;
|
|
1134
1280
|
let toolErrors = 0;
|
|
1135
1281
|
const toolBudget = resolveNativeToolBudget();
|
|
1282
|
+
const usage = createUsageTotals();
|
|
1136
1283
|
|
|
1137
1284
|
while (true) {
|
|
1138
1285
|
guards.ensureActive();
|
|
@@ -1159,6 +1306,9 @@ async function runNativeLoop({
|
|
|
1159
1306
|
},
|
|
1160
1307
|
});
|
|
1161
1308
|
|
|
1309
|
+
usage.turns += 1;
|
|
1310
|
+
addUsageTotals(usage, turnResult && turnResult.usage);
|
|
1311
|
+
|
|
1162
1312
|
const toolCalls = transport.getToolCalls(turnResult);
|
|
1163
1313
|
|
|
1164
1314
|
if (toolCalls.length === 0) {
|
|
@@ -1172,6 +1322,7 @@ async function runNativeLoop({
|
|
|
1172
1322
|
streamed,
|
|
1173
1323
|
toolCallsExecuted,
|
|
1174
1324
|
messages,
|
|
1325
|
+
usage,
|
|
1175
1326
|
};
|
|
1176
1327
|
}
|
|
1177
1328
|
|
|
@@ -1182,6 +1333,7 @@ async function runNativeLoop({
|
|
|
1182
1333
|
streamed,
|
|
1183
1334
|
toolCallsExecuted,
|
|
1184
1335
|
messages,
|
|
1336
|
+
usage,
|
|
1185
1337
|
};
|
|
1186
1338
|
}
|
|
1187
1339
|
|
|
@@ -1312,12 +1464,27 @@ async function runNativeAgentTask({
|
|
|
1312
1464
|
: ""
|
|
1313
1465
|
);
|
|
1314
1466
|
|
|
1467
|
+
const usage = runResult.usage && typeof runResult.usage === "object"
|
|
1468
|
+
? runResult.usage
|
|
1469
|
+
: createUsageTotals();
|
|
1470
|
+
appendUsageRecord(workspaceRoot, {
|
|
1471
|
+
sessionId: nextSessionId,
|
|
1472
|
+
model: runtime.model,
|
|
1473
|
+
provider: runtime.provider,
|
|
1474
|
+
turns: usage.turns,
|
|
1475
|
+
input: usage.input,
|
|
1476
|
+
output: usage.output,
|
|
1477
|
+
cacheRead: usage.cacheRead,
|
|
1478
|
+
cacheCreation: usage.cacheCreation,
|
|
1479
|
+
});
|
|
1480
|
+
|
|
1315
1481
|
return {
|
|
1316
1482
|
ok: true,
|
|
1317
1483
|
error: "",
|
|
1318
1484
|
output: outputText,
|
|
1319
1485
|
messages: cloneMessageList(runResult.messages),
|
|
1320
1486
|
sessionId: nextSessionId,
|
|
1487
|
+
usage,
|
|
1321
1488
|
// The loop marks streamed=true whenever it receives a stream callback;
|
|
1322
1489
|
// only report it when the caller actually registered one.
|
|
1323
1490
|
streamed: Boolean(runResult.streamed) && typeof onStreamDelta === "function",
|
package/src/code/repl.js
CHANGED
|
@@ -21,6 +21,7 @@ const {
|
|
|
21
21
|
getPendingBusCount,
|
|
22
22
|
shouldAutoConsumeBus,
|
|
23
23
|
} = require("./busConsumer");
|
|
24
|
+
const { summarizeSessionUsage } = require("./usageStore");
|
|
24
25
|
|
|
25
26
|
function printPrompt(stdout = process.stdout) {
|
|
26
27
|
stdout.write("> ");
|
|
@@ -74,6 +75,20 @@ function extractAgentNickname(agentId = "") {
|
|
|
74
75
|
return base;
|
|
75
76
|
}
|
|
76
77
|
|
|
78
|
+
function formatSessionUsageStatus(summary = {}) {
|
|
79
|
+
const source = summary && typeof summary === "object" ? summary : {};
|
|
80
|
+
const input = Number(source.input) || 0;
|
|
81
|
+
const output = Number(source.output) || 0;
|
|
82
|
+
const cacheRead = Number(source.cacheRead) || 0;
|
|
83
|
+
const cacheCreation = Number(source.cacheCreation) || 0;
|
|
84
|
+
const denominator = cacheRead + input;
|
|
85
|
+
const hitRate = denominator > 0 ? (cacheRead / denominator) * 100 : 0;
|
|
86
|
+
return [
|
|
87
|
+
`Session tokens: input=${input} output=${output} cache_read=${cacheRead} cache_creation=${cacheCreation}`,
|
|
88
|
+
`Cache hit rate: ${hitRate.toFixed(1)}% (cache_read/(cache_read+input))`,
|
|
89
|
+
].join("\n");
|
|
90
|
+
}
|
|
91
|
+
|
|
77
92
|
function runSingleCommand(line = "", workspaceRoot = process.cwd()) {
|
|
78
93
|
const text = normalizeLine(line);
|
|
79
94
|
if (!text) return { kind: "empty" };
|
|
@@ -86,6 +101,7 @@ function runSingleCommand(line = "", workspaceRoot = process.cwd()) {
|
|
|
86
101
|
" help",
|
|
87
102
|
" exit|quit",
|
|
88
103
|
" ubus|/ubus",
|
|
104
|
+
" status|/status",
|
|
89
105
|
" skills [list]",
|
|
90
106
|
" skills show <name>",
|
|
91
107
|
" bg|/bg <task>",
|
|
@@ -107,6 +123,11 @@ function runSingleCommand(line = "", workspaceRoot = process.cwd()) {
|
|
|
107
123
|
kind: "ubus",
|
|
108
124
|
};
|
|
109
125
|
}
|
|
126
|
+
if (text === "status" || text === "/status") {
|
|
127
|
+
return {
|
|
128
|
+
kind: "status",
|
|
129
|
+
};
|
|
130
|
+
}
|
|
110
131
|
const skillsMatch = text.match(/^(?:\/skills|skills)(?:\s+(.*))?$/i);
|
|
111
132
|
if (skillsMatch) {
|
|
112
133
|
const args = String(skillsMatch[1] || "").trim().split(/\s+/).filter(Boolean);
|
|
@@ -410,6 +431,13 @@ async function runUcodeCoreAgent({
|
|
|
410
431
|
if (result.kind === "help" || result.kind === "tool" || result.kind === "skills" || result.kind === "error") {
|
|
411
432
|
stdout.write(`${result.output}\n`);
|
|
412
433
|
}
|
|
434
|
+
if (result.kind === "status") {
|
|
435
|
+
const usageSummary = summarizeSessionUsage({
|
|
436
|
+
workspaceRoot: runtimeWorkspace,
|
|
437
|
+
sessionId: state.sessionId,
|
|
438
|
+
});
|
|
439
|
+
stdout.write(`${formatSessionUsageStatus(usageSummary)}\n`);
|
|
440
|
+
}
|
|
413
441
|
if (result.kind === "ubus") {
|
|
414
442
|
const ubusResult = await runUbusCommand(state, {
|
|
415
443
|
workspaceRoot: runtimeWorkspace,
|
|
@@ -607,4 +635,5 @@ module.exports = {
|
|
|
607
635
|
runSingleCommand,
|
|
608
636
|
extractAgentNickname,
|
|
609
637
|
parseAgentArgs,
|
|
638
|
+
formatSessionUsageStatus,
|
|
610
639
|
};
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
const fs = require("fs");
|
|
2
|
+
const path = require("path");
|
|
3
|
+
|
|
4
|
+
function getUsageFilePath(workspaceRoot = process.cwd()) {
|
|
5
|
+
const root = path.resolve(workspaceRoot || process.cwd());
|
|
6
|
+
return path.join(root, ".ufoo", "agent", "ucode", "usage.jsonl");
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function toUsageCount(value) {
|
|
10
|
+
const parsed = Number(value);
|
|
11
|
+
if (!Number.isFinite(parsed) || parsed <= 0) return 0;
|
|
12
|
+
return Math.floor(parsed);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function buildUsageRecord(input = {}) {
|
|
16
|
+
const source = input && typeof input === "object" ? input : {};
|
|
17
|
+
return {
|
|
18
|
+
ts: String(source.ts || "").trim() || new Date().toISOString(),
|
|
19
|
+
sessionId: String(source.sessionId || "").trim(),
|
|
20
|
+
model: String(source.model || "").trim(),
|
|
21
|
+
provider: String(source.provider || "").trim(),
|
|
22
|
+
turns: toUsageCount(source.turns),
|
|
23
|
+
input: toUsageCount(source.input),
|
|
24
|
+
output: toUsageCount(source.output),
|
|
25
|
+
cacheRead: toUsageCount(source.cacheRead),
|
|
26
|
+
cacheCreation: toUsageCount(source.cacheCreation),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function appendUsageRecord(workspaceRoot = process.cwd(), record = {}) {
|
|
31
|
+
const row = buildUsageRecord(record);
|
|
32
|
+
const filePath = getUsageFilePath(workspaceRoot);
|
|
33
|
+
try {
|
|
34
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
35
|
+
fs.appendFileSync(filePath, `${JSON.stringify(row)}\n`, "utf8");
|
|
36
|
+
return {
|
|
37
|
+
ok: true,
|
|
38
|
+
error: "",
|
|
39
|
+
filePath,
|
|
40
|
+
record: row,
|
|
41
|
+
};
|
|
42
|
+
} catch (err) {
|
|
43
|
+
// Usage accounting is observability only: never let a write failure
|
|
44
|
+
// break the agent loop.
|
|
45
|
+
return {
|
|
46
|
+
ok: false,
|
|
47
|
+
error: err && err.message ? err.message : "failed to append usage",
|
|
48
|
+
filePath,
|
|
49
|
+
record: row,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function createUsageSummary() {
|
|
55
|
+
return {
|
|
56
|
+
records: 0,
|
|
57
|
+
turns: 0,
|
|
58
|
+
input: 0,
|
|
59
|
+
output: 0,
|
|
60
|
+
cacheRead: 0,
|
|
61
|
+
cacheCreation: 0,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function summarizeSessionUsage({ workspaceRoot = process.cwd(), sessionId = "" } = {}) {
|
|
66
|
+
const summary = createUsageSummary();
|
|
67
|
+
const targetSessionId = String(sessionId || "").trim();
|
|
68
|
+
let raw = "";
|
|
69
|
+
try {
|
|
70
|
+
raw = fs.readFileSync(getUsageFilePath(workspaceRoot), "utf8");
|
|
71
|
+
} catch {
|
|
72
|
+
return summary;
|
|
73
|
+
}
|
|
74
|
+
for (const line of String(raw).split(/\r?\n/)) {
|
|
75
|
+
const text = line.trim();
|
|
76
|
+
if (!text) continue;
|
|
77
|
+
let row = null;
|
|
78
|
+
try {
|
|
79
|
+
row = JSON.parse(text);
|
|
80
|
+
} catch {
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
if (!row || typeof row !== "object" || Array.isArray(row)) continue;
|
|
84
|
+
if (targetSessionId && String(row.sessionId || "").trim() !== targetSessionId) continue;
|
|
85
|
+
summary.records += 1;
|
|
86
|
+
summary.turns += toUsageCount(row.turns);
|
|
87
|
+
summary.input += toUsageCount(row.input);
|
|
88
|
+
summary.output += toUsageCount(row.output);
|
|
89
|
+
summary.cacheRead += toUsageCount(row.cacheRead);
|
|
90
|
+
summary.cacheCreation += toUsageCount(row.cacheCreation);
|
|
91
|
+
}
|
|
92
|
+
return summary;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
module.exports = {
|
|
96
|
+
getUsageFilePath,
|
|
97
|
+
buildUsageRecord,
|
|
98
|
+
appendUsageRecord,
|
|
99
|
+
createUsageSummary,
|
|
100
|
+
summarizeSessionUsage,
|
|
101
|
+
};
|
package/src/ui/ink/ChatApp.js
CHANGED
|
@@ -1264,7 +1264,16 @@ function createChatApp({ React, ink, props, interactive = true }) {
|
|
|
1264
1264
|
appendChatHistory(activeChatHistoryRoot, kind, text, meta, activeChatHistoryOptions);
|
|
1265
1265
|
}, [activeChatHistoryRoot, activeChatHistoryOptions.globalMode]);
|
|
1266
1266
|
|
|
1267
|
+
// Terminal statuses (command replies, ✓/✗ resolutions) must not animate
|
|
1268
|
+
// forever; show them briefly, then revert the status line to Ready.
|
|
1269
|
+
const statusAutoClearRef = useRef(null);
|
|
1270
|
+
|
|
1267
1271
|
const setStatusText = useCallback((text, options = {}) => {
|
|
1272
|
+
// Any new status supersedes a pending auto-clear.
|
|
1273
|
+
if (statusAutoClearRef.current) {
|
|
1274
|
+
clearTimeout(statusAutoClearRef.current);
|
|
1275
|
+
statusAutoClearRef.current = null;
|
|
1276
|
+
}
|
|
1268
1277
|
const clean = stripBlessedTags(text).trim();
|
|
1269
1278
|
if (!clean) {
|
|
1270
1279
|
dispatch({ type: "status/idle" });
|
|
@@ -1282,6 +1291,15 @@ function createChatApp({ React, ink, props, interactive = true }) {
|
|
|
1282
1291
|
});
|
|
1283
1292
|
}, []);
|
|
1284
1293
|
|
|
1294
|
+
const scheduleStatusAutoClear = useCallback(() => {
|
|
1295
|
+
if (statusAutoClearRef.current) clearTimeout(statusAutoClearRef.current);
|
|
1296
|
+
statusAutoClearRef.current = setTimeout(() => {
|
|
1297
|
+
statusAutoClearRef.current = null;
|
|
1298
|
+
dispatch({ type: "status/idle" });
|
|
1299
|
+
}, 5000);
|
|
1300
|
+
if (typeof statusAutoClearRef.current.unref === "function") statusAutoClearRef.current.unref();
|
|
1301
|
+
}, []);
|
|
1302
|
+
|
|
1285
1303
|
const logInkMessage = useCallback((kind, text, meta = {}) => {
|
|
1286
1304
|
const type = String(kind || "system");
|
|
1287
1305
|
if (type === "status") {
|
|
@@ -1786,13 +1804,18 @@ function createChatApp({ React, ink, props, interactive = true }) {
|
|
|
1786
1804
|
updateDashboard: updateDashboardFromStatus,
|
|
1787
1805
|
requestStatus: requestDaemonStatus,
|
|
1788
1806
|
resolveStatusLine: (text, data = {}) => {
|
|
1807
|
+
// Terminal status: static (no UFO) and auto-revert to Ready.
|
|
1789
1808
|
setStatusText(text, {
|
|
1790
|
-
type: data && data.phase === "error" ? "error" : "
|
|
1809
|
+
type: data && data.phase === "error" ? "error" : "none",
|
|
1791
1810
|
showTimer: false,
|
|
1792
1811
|
});
|
|
1812
|
+
scheduleStatusAutoClear();
|
|
1793
1813
|
},
|
|
1794
1814
|
enqueueBusStatus: (item = {}) => setStatusText(item.text || "Processing bus message", { type: "typing" }),
|
|
1795
|
-
resolveBusStatus: (item = {}) =>
|
|
1815
|
+
resolveBusStatus: (item = {}) => {
|
|
1816
|
+
setStatusText(item.text || "Bus message processed", { type: "done" });
|
|
1817
|
+
scheduleStatusAutoClear();
|
|
1818
|
+
},
|
|
1796
1819
|
getPending: () => pendingRef.current,
|
|
1797
1820
|
setPending: (value) => { pendingRef.current = value || null; },
|
|
1798
1821
|
resolveAgentDisplayName: (value) => {
|
|
@@ -74,6 +74,7 @@ function formatLoopSummary(loopSummary) {
|
|
|
74
74
|
const rounds = Number(loopSummary.rounds) || 0;
|
|
75
75
|
const toolCalls = Number(loopSummary.tool_calls) || 0;
|
|
76
76
|
const totalTokens = Number(loopSummary.total_tokens) || 0;
|
|
77
|
+
const inputTokens = Number(loopSummary.input_tokens) || 0;
|
|
77
78
|
const cacheReadTokens = Number(loopSummary.cache_read_tokens) || 0;
|
|
78
79
|
const cacheCreationTokens = Number(loopSummary.cache_creation_tokens) || 0;
|
|
79
80
|
const terminalReason = String(loopSummary.terminal_reason || "").trim();
|
|
@@ -81,7 +82,12 @@ function formatLoopSummary(loopSummary) {
|
|
|
81
82
|
if (rounds <= 0 && toolCalls <= 0 && totalTokens <= 0 && !terminalReason && !toolDistribution) return "";
|
|
82
83
|
const parts = [`r${rounds}`, `tc${toolCalls}`, `tok${totalTokens}`];
|
|
83
84
|
if (cacheReadTokens > 0 || cacheCreationTokens > 0) {
|
|
84
|
-
|
|
85
|
+
let cachePart = `cache${cacheReadTokens}/${cacheCreationTokens}`;
|
|
86
|
+
if (cacheReadTokens > 0) {
|
|
87
|
+
const hitRate = Math.round((cacheReadTokens / (cacheReadTokens + inputTokens)) * 100);
|
|
88
|
+
cachePart += `(${hitRate}%)`;
|
|
89
|
+
}
|
|
90
|
+
parts.push(cachePart);
|
|
85
91
|
}
|
|
86
92
|
if (toolDistribution) parts.push(toolDistribution);
|
|
87
93
|
if (terminalReason) parts.push(terminalReason);
|