u-foo 2.5.5 → 2.5.7

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.
@@ -14,6 +14,11 @@ const DEFAULT_ANTHROPIC_BASE_URL = "https://api.anthropic.com/v1";
14
14
  // for non-trivial tasks while still catching runaway loops. Override via env.
15
15
  const DEFAULT_MAX_NATIVE_TOOL_CALLS = 100;
16
16
  const DEFAULT_MAX_NATIVE_TOOL_ERRORS = 5;
17
+ // Anthropic Messages rejects max_tokens above the model's real cap (64K on
18
+ // current models), so the transports use different defaults. Override either
19
+ // via UFOO_UCODE_MAX_TOKENS (positive integer).
20
+ const DEFAULT_OPENAI_MAX_TOKENS = 131072;
21
+ const DEFAULT_ANTHROPIC_MAX_TOKENS = 64000;
17
22
 
18
23
  function nowMs() {
19
24
  return Date.now();
@@ -38,6 +43,10 @@ function resolveNativeToolBudget(env = process.env) {
38
43
  };
39
44
  }
40
45
 
46
+ function resolveMaxTokens(fallback) {
47
+ return normalizePositiveInt(process.env.UFOO_UCODE_MAX_TOKENS, fallback);
48
+ }
49
+
41
50
  function enforceNativeToolBudget({
42
51
  toolCallsExecuted = 0,
43
52
  toolErrors = 0,
@@ -46,7 +55,7 @@ function enforceNativeToolBudget({
46
55
  lastTool = "",
47
56
  lastError = "",
48
57
  } = {}) {
49
- if (toolCallsExecuted > maxToolCalls) {
58
+ if (toolCallsExecuted >= maxToolCalls) {
50
59
  throw new Error(`tool call budget exceeded (${maxToolCalls})`);
51
60
  }
52
61
  if (toolErrors >= maxToolErrors) {
@@ -93,104 +102,6 @@ function clipText(value = "", maxChars = 6000) {
93
102
  return `${text.slice(0, maxChars)}\n...[truncated]`;
94
103
  }
95
104
 
96
- function summarizeFileSnippet(file = "", content = "") {
97
- const target = String(file || "").trim();
98
- const body = String(content || "").trim();
99
- if (!body) return `${target}: empty`;
100
-
101
- if (target.toLowerCase().endsWith("package.json")) {
102
- try {
103
- const parsed = JSON.parse(body);
104
- const name = String(parsed.name || "").trim() || "(unknown)";
105
- const version = String(parsed.version || "").trim() || "(unknown)";
106
- const scripts = parsed.scripts && typeof parsed.scripts === "object"
107
- ? Object.keys(parsed.scripts).slice(0, 4)
108
- : [];
109
- const scriptText = scripts.length > 0 ? ` scripts=${scripts.join(",")}` : "";
110
- return `${target}: name=${name} version=${version}${scriptText}`;
111
- } catch {
112
- // fall through
113
- }
114
- }
115
-
116
- const lines = body
117
- .split(/\r?\n/)
118
- .map((line) => line.trim())
119
- .filter(Boolean)
120
- .slice(0, 3)
121
- .map((line) => (line.length > 120 ? `${line.slice(0, 120)}...` : line));
122
-
123
- if (lines.length === 0) return `${target}: empty`;
124
- return `${target}: ${lines.join(" | ")}`;
125
- }
126
-
127
- function extractPreflightEvidence(systemPrompt = "") {
128
- const source = String(systemPrompt || "");
129
- if (!source) return [];
130
-
131
- const results = [];
132
- const fileRegex = /File:\s*([^\n]+)\n([\s\S]*?)(?=\n---\n(?:File|Command):|$)/g;
133
- let match = fileRegex.exec(source);
134
- while (match) {
135
- const file = String(match[1] || "").trim();
136
- const content = String(match[2] || "").trim();
137
- if (file) {
138
- results.push({ kind: "file", label: file, summary: summarizeFileSnippet(file, content) });
139
- }
140
- match = fileRegex.exec(source);
141
- }
142
-
143
- const cmdRegex = /Command:\s*([^\n]+)\n([\s\S]*?)(?=\n---\n(?:File|Command):|$)/g;
144
- match = cmdRegex.exec(source);
145
- while (match) {
146
- const command = String(match[1] || "").trim();
147
- const output = String(match[2] || "").trim();
148
- if (command) {
149
- const clipped = clipText(output, 300).replace(/\s+/g, " ").trim();
150
- results.push({ kind: "command", label: command, summary: `${command}: ${clipped || "(no output)"}` });
151
- }
152
- match = cmdRegex.exec(source);
153
- }
154
-
155
- return results;
156
- }
157
-
158
- function isAnalysisPrompt(text = "") {
159
- return /(?:analy[sz]e|analysis|review|audit|status|architecture|codebase|repo|project|现状|架构|审查|分析|项目|代码库)/i.test(text);
160
- }
161
-
162
- function parseReadIntent(prompt = "") {
163
- const text = String(prompt || "");
164
- const patterns = [
165
- /(?:\bread\b|\bcat\b|查看|读取)\s+([A-Za-z0-9_./\\-]+(?:\.[A-Za-z0-9._-]+)?)/i,
166
- /([A-Za-z0-9_./\\-]+\.(?:md|txt|json|js|ts|jsx|tsx|yml|yaml|toml|sh))/i,
167
- ];
168
- for (const re of patterns) {
169
- const match = text.match(re);
170
- if (!match || !match[1]) continue;
171
- const candidate = String(match[1]).trim().replace(/[),.;:]+$/, "");
172
- if (!candidate) continue;
173
- if (candidate.length > 260) continue;
174
- return candidate;
175
- }
176
- return "";
177
- }
178
-
179
- function parseBashIntent(prompt = "") {
180
- const text = String(prompt || "").trim();
181
- if (!text) return "";
182
- if (
183
- /\b(ls|dir|tree)\b/i.test(text)
184
- || /\b(list|show)\s+(files|dirs|directories|folders)\b/i.test(text)
185
- || /列出|目录|文件列表/.test(text)
186
- ) {
187
- return "ls -la";
188
- }
189
- const cmdMatch = text.match(/(?:运行|执行|run|exec(?:ute)?)\s+`([^`]+)`/i);
190
- if (cmdMatch && cmdMatch[1]) return String(cmdMatch[1]).trim();
191
- return "";
192
- }
193
-
194
105
  function normalizeProvider(value = "") {
195
106
  const text = String(value || "").trim().toLowerCase();
196
107
  if (!text) return "";
@@ -308,6 +219,11 @@ function buildCoreToolSpecs() {
308
219
  properties: {
309
220
  path: { type: "string" },
310
221
  content: { type: "string" },
222
+ mode: {
223
+ type: "string",
224
+ enum: ["overwrite", "append"],
225
+ description: 'Write mode: "overwrite" replaces the file (default), "append" adds to its end.',
226
+ },
311
227
  append: { type: "boolean" },
312
228
  },
313
229
  required: ["path", "content"],
@@ -534,7 +450,7 @@ async function runOpenAiLikeTurn({
534
450
  } = {}) {
535
451
  const payload = {
536
452
  model,
537
- max_tokens: 131072,
453
+ max_tokens: resolveMaxTokens(DEFAULT_OPENAI_MAX_TOKENS),
538
454
  messages,
539
455
  tools: buildCoreToolSpecs(),
540
456
  tool_choice: "auto",
@@ -588,6 +504,9 @@ async function runOpenAiLikeTurn({
588
504
  let rawBuffer = "";
589
505
  let responseText = "";
590
506
  const announcedToolNames = new Set();
507
+ let nextSyntheticIndex = 0;
508
+ let lastSyntheticIndex = -1;
509
+ let sawDone = false;
591
510
 
592
511
  while (true) {
593
512
  const { done, value } = await reader.read();
@@ -601,8 +520,11 @@ async function runOpenAiLikeTurn({
601
520
  const payloadText = parseSseDataBlock(block);
602
521
  if (!payloadText) continue;
603
522
  if (payloadText === "[DONE]") {
604
- rawBuffer = "";
605
- break;
523
+ // Stop reading after this batch, but keep the buffered tail and
524
+ // finish the blocks already parsed alongside [DONE] instead of
525
+ // silently dropping them.
526
+ sawDone = true;
527
+ continue;
606
528
  }
607
529
 
608
530
  const chunk = parseJsonSafe(payloadText, null);
@@ -633,7 +555,23 @@ async function runOpenAiLikeTurn({
633
555
 
634
556
  if (Array.isArray(delta.tool_calls)) {
635
557
  for (const callPart of delta.tool_calls) {
636
- const index = Number.isFinite(callPart.index) ? callPart.index : 0;
558
+ let index;
559
+ if (Number.isFinite(callPart.index)) {
560
+ index = callPart.index;
561
+ } else if (typeof callPart.id === "string" && callPart.id) {
562
+ // Provider omitted index: a chunk carrying an id starts a new
563
+ // call, so give it its own synthetic index instead of
564
+ // collapsing every call into slot 0.
565
+ while (toolCallMap.has(nextSyntheticIndex)) nextSyntheticIndex += 1;
566
+ index = nextSyntheticIndex;
567
+ nextSyntheticIndex += 1;
568
+ lastSyntheticIndex = index;
569
+ } else if (lastSyntheticIndex >= 0) {
570
+ // No index and no id: continuation of the latest synthetic call.
571
+ index = lastSyntheticIndex;
572
+ } else {
573
+ index = 0;
574
+ }
637
575
  const previous = toolCallMap.get(index) || {
638
576
  id: "",
639
577
  type: "function",
@@ -664,6 +602,8 @@ async function runOpenAiLikeTurn({
664
602
  }
665
603
  }
666
604
  }
605
+
606
+ if (sawDone) break;
667
607
  }
668
608
 
669
609
  if (rawBuffer.trim()) {
@@ -755,7 +695,7 @@ async function runAnthropicTurn({
755
695
  } = {}) {
756
696
  const payload = {
757
697
  model,
758
- max_tokens: 131072,
698
+ max_tokens: resolveMaxTokens(DEFAULT_ANTHROPIC_MAX_TOKENS),
759
699
  messages,
760
700
  tools: buildAnthropicToolSpecs(),
761
701
  stream: true,
@@ -812,6 +752,9 @@ async function runAnthropicTurn({
812
752
  const blockMap = new Map();
813
753
  let rawBuffer = "";
814
754
  let responseText = "";
755
+ let nextSyntheticBlockIndex = 0;
756
+ let lastBlockIndex = -1;
757
+ let sawDone = false;
815
758
 
816
759
  while (true) {
817
760
  const { done, value } = await reader.read();
@@ -823,7 +766,13 @@ async function runAnthropicTurn({
823
766
 
824
767
  for (const rawBlock of parsed.blocks) {
825
768
  const { event, data } = parseSseEventBlock(rawBlock);
826
- if (!data || data === "[DONE]") continue;
769
+ if (!data) continue;
770
+ if (data === "[DONE]") {
771
+ // Stop reading after this batch instead of waiting for the server
772
+ // to close the connection, mirroring the OpenAI transport.
773
+ sawDone = true;
774
+ continue;
775
+ }
827
776
 
828
777
  const payloadChunk = parseJsonSafe(data, null);
829
778
  if (!payloadChunk || typeof payloadChunk !== "object") continue;
@@ -836,7 +785,18 @@ async function runAnthropicTurn({
836
785
  }
837
786
 
838
787
  if (event === "content_block_start") {
839
- const index = Number.isFinite(payloadChunk.index) ? payloadChunk.index : 0;
788
+ let index;
789
+ if (Number.isFinite(payloadChunk.index)) {
790
+ index = payloadChunk.index;
791
+ } else {
792
+ // Provider omitted index: each start opens a new block, so give
793
+ // it its own synthetic index instead of collapsing every block
794
+ // into slot 0.
795
+ while (blockMap.has(nextSyntheticBlockIndex)) nextSyntheticBlockIndex += 1;
796
+ index = nextSyntheticBlockIndex;
797
+ nextSyntheticBlockIndex += 1;
798
+ }
799
+ lastBlockIndex = index;
840
800
  const contentBlock = payloadChunk.content_block && typeof payloadChunk.content_block === "object"
841
801
  ? payloadChunk.content_block
842
802
  : {};
@@ -873,7 +833,15 @@ async function runAnthropicTurn({
873
833
  }
874
834
 
875
835
  if (event === "content_block_delta") {
876
- const index = Number.isFinite(payloadChunk.index) ? payloadChunk.index : 0;
836
+ let index;
837
+ if (Number.isFinite(payloadChunk.index)) {
838
+ index = payloadChunk.index;
839
+ } else if (lastBlockIndex >= 0) {
840
+ // No index: continuation of the most recently started block.
841
+ index = lastBlockIndex;
842
+ } else {
843
+ index = 0;
844
+ }
877
845
  const delta = payloadChunk.delta && typeof payloadChunk.delta === "object"
878
846
  ? payloadChunk.delta
879
847
  : {};
@@ -916,6 +884,8 @@ async function runAnthropicTurn({
916
884
  }
917
885
  }
918
886
  }
887
+
888
+ if (sawDone) break;
919
889
  }
920
890
 
921
891
  const assistantContent = Array.from(blockMap.values())
@@ -1276,6 +1246,17 @@ async function runNativeAgentTask({
1276
1246
  const guards = createGuards({ signal, timeoutMs });
1277
1247
  const nextSessionId = String(sessionId || "").trim() || `native-${randomUUID()}`;
1278
1248
  const promptText = String(prompt || "").trim();
1249
+ // Track every text delta so the error path can return the partial output
1250
+ // the model already produced instead of discarding it.
1251
+ let partialOutput = "";
1252
+ const trackingStreamDelta = (chunk) => {
1253
+ const text = String(chunk || "");
1254
+ if (!text) return;
1255
+ partialOutput += text;
1256
+ if (typeof onStreamDelta === "function") {
1257
+ onStreamDelta(chunk);
1258
+ }
1259
+ };
1279
1260
 
1280
1261
  try {
1281
1262
  guards.ensureActive();
@@ -1309,7 +1290,7 @@ async function runNativeAgentTask({
1309
1290
  baseUrl: runtime.baseUrl,
1310
1291
  apiKey: runtime.apiKey,
1311
1292
  timeoutMs,
1312
- onStreamDelta,
1293
+ onStreamDelta: trackingStreamDelta,
1313
1294
  onThinkingDelta,
1314
1295
  onPhase,
1315
1296
  onToolEvent,
@@ -1329,14 +1310,16 @@ async function runNativeAgentTask({
1329
1310
  output: outputText,
1330
1311
  messages: cloneMessageList(runResult.messages),
1331
1312
  sessionId: nextSessionId,
1332
- streamed: Boolean(runResult.streamed),
1313
+ // The loop marks streamed=true whenever it receives a stream callback;
1314
+ // only report it when the caller actually registered one.
1315
+ streamed: Boolean(runResult.streamed) && typeof onStreamDelta === "function",
1333
1316
  };
1334
1317
  } catch (err) {
1335
1318
  const message = err && err.message ? err.message : "native runner failed";
1336
1319
  return {
1337
1320
  ok: false,
1338
1321
  error: message,
1339
- output: "",
1322
+ output: partialOutput.trim(),
1340
1323
  sessionId: nextSessionId,
1341
1324
  streamed: false,
1342
1325
  };
@@ -1345,9 +1328,6 @@ async function runNativeAgentTask({
1345
1328
 
1346
1329
  module.exports = {
1347
1330
  runNativeAgentTask,
1348
- parseReadIntent,
1349
- parseBashIntent,
1350
- extractPreflightEvidence,
1351
1331
  resolveRuntimeConfig,
1352
1332
  resolveCompletionUrl,
1353
1333
  resolveAnthropicMessagesUrl,