u-foo 1.9.8 → 2.2.0

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.
Files changed (69) hide show
  1. package/package.json +2 -4
  2. package/src/agent/claudeEventTranslator.js +267 -0
  3. package/src/agent/claudeOauthTokenReader.js +52 -0
  4. package/src/agent/claudeThreadProvider.js +343 -0
  5. package/src/agent/cliRunner.js +4 -16
  6. package/src/agent/codexEventTranslator.js +78 -0
  7. package/src/agent/codexThreadProvider.js +181 -0
  8. package/src/agent/controllerToolExecutor.js +233 -0
  9. package/src/agent/credentials/claude.js +324 -0
  10. package/src/agent/credentials/codex.js +203 -0
  11. package/src/agent/credentials/index.js +106 -0
  12. package/src/agent/defaultBootstrap.js +128 -5
  13. package/src/agent/internalRunner.js +333 -2
  14. package/src/agent/loopObservability.js +190 -0
  15. package/src/agent/loopRuntime.js +457 -0
  16. package/src/agent/ufooAgent.js +178 -120
  17. package/src/agent/upstreamTransport.js +464 -0
  18. package/src/bus/utils.js +3 -2
  19. package/src/chat/dashboardView.js +51 -1
  20. package/src/chat/index.js +3 -1
  21. package/src/config.js +53 -17
  22. package/src/controller/flags.js +160 -0
  23. package/src/controller/gateRouter.js +201 -0
  24. package/src/controller/routerFastPath.js +22 -0
  25. package/src/controller/shadowGuard.js +280 -0
  26. package/src/daemon/index.js +2 -3
  27. package/src/daemon/promptLoop.js +33 -224
  28. package/src/daemon/promptRequest.js +360 -5
  29. package/src/daemon/status.js +2 -0
  30. package/src/history/inputTimeline.js +9 -4
  31. package/src/memory/index.js +24 -0
  32. package/src/providerapi/redactor.js +87 -0
  33. package/src/providerapi/shadowDiff.js +174 -0
  34. package/src/report/store.js +4 -3
  35. package/src/tools/handlers/ackBus.js +26 -0
  36. package/src/tools/handlers/common.js +64 -0
  37. package/src/tools/handlers/dispatchMessage.js +81 -0
  38. package/src/tools/handlers/listAgents.js +14 -0
  39. package/src/tools/handlers/readBusSummary.js +34 -0
  40. package/src/tools/handlers/readOpenDecisions.js +26 -0
  41. package/src/tools/handlers/readProjectRegistry.js +20 -0
  42. package/src/tools/handlers/readPromptHistory.js +123 -0
  43. package/src/tools/handlers/tier2.js +134 -0
  44. package/src/tools/index.js +55 -0
  45. package/src/tools/registry.js +69 -0
  46. package/src/tools/schemaFixtures.js +415 -0
  47. package/src/tools/tier0/listAgents.js +14 -0
  48. package/src/tools/tier0/readBusSummary.js +14 -0
  49. package/src/tools/tier0/readOpenDecisions.js +14 -0
  50. package/src/tools/tier0/readProjectRegistry.js +14 -0
  51. package/src/tools/tier0/readPromptHistory.js +14 -0
  52. package/src/tools/tier1/ackBus.js +14 -0
  53. package/src/tools/tier1/dispatchMessage.js +14 -0
  54. package/src/tools/tier1/routeAgent.js +14 -0
  55. package/src/tools/tier2/closeAgent.js +14 -0
  56. package/src/tools/tier2/launchAgent.js +14 -0
  57. package/src/tools/tier2/manageCron.js +14 -0
  58. package/src/tools/tier2/renameAgent.js +14 -0
  59. package/src/tools/types.js +75 -0
  60. package/src/tools/unimplemented.js +13 -0
  61. package/src/ufoo/paths.js +4 -0
  62. package/bin/ufoo-assistant-agent.js +0 -5
  63. package/bin/ufoo-engine.js +0 -25
  64. package/src/assistant/agent.js +0 -261
  65. package/src/assistant/bridge.js +0 -178
  66. package/src/assistant/constants.js +0 -15
  67. package/src/assistant/engine.js +0 -252
  68. package/src/assistant/stdio.js +0 -58
  69. package/src/assistant/ufooEngineCli.js +0 -312
@@ -0,0 +1,190 @@
1
+ "use strict";
2
+
3
+ const fs = require("fs");
4
+ const path = require("path");
5
+ const { getUfooPaths } = require("../ufoo/paths");
6
+ const { redactSecrets } = require("../providerapi/redactor");
7
+
8
+ const LOOP_EVENT_SCHEMA_VERSION = 1;
9
+
10
+ function getLoopObservabilityPaths(projectRoot) {
11
+ const { agentDir } = getUfooPaths(projectRoot);
12
+ return {
13
+ eventsFile: path.join(agentDir, "ufoo-agent.loop-events.jsonl"),
14
+ auditFile: path.join(agentDir, "ufoo-agent.audit.jsonl"),
15
+ };
16
+ }
17
+
18
+ function getShadowObservabilityPaths(projectRoot, now = new Date()) {
19
+ const { ufooDir } = getUfooPaths(projectRoot);
20
+ const stamp = now.toISOString().slice(0, 10);
21
+ return {
22
+ shadowDir: path.join(ufooDir, "shadow"),
23
+ diffFile: path.join(ufooDir, "shadow", `diff-${stamp}.jsonl`),
24
+ };
25
+ }
26
+
27
+ function appendJsonLine(file, payload) {
28
+ fs.mkdirSync(path.dirname(file), { recursive: true });
29
+ fs.appendFileSync(file, `${JSON.stringify(redactSecrets(payload))}\n`, "utf8");
30
+ }
31
+
32
+ function appendShadowDiff(projectRoot, payload = {}, now = new Date()) {
33
+ if (!projectRoot) return null;
34
+ const { diffFile } = getShadowObservabilityPaths(projectRoot, now);
35
+ appendJsonLine(diffFile, {
36
+ schema_version: LOOP_EVENT_SCHEMA_VERSION,
37
+ ts: now.toISOString(),
38
+ shadow_only: true,
39
+ ...payload,
40
+ });
41
+ return diffFile;
42
+ }
43
+
44
+ function createLoopObserver({ projectRoot, enabled = true, defaults = {} } = {}) {
45
+ const paths = projectRoot ? getLoopObservabilityPaths(projectRoot) : null;
46
+ const baseDefaults = defaults && typeof defaults === "object" ? { ...defaults } : {};
47
+
48
+ function emit(event, payload = {}) {
49
+ if (!enabled || !paths) return;
50
+ appendJsonLine(paths.eventsFile, {
51
+ schema_version: LOOP_EVENT_SCHEMA_VERSION,
52
+ ts: new Date().toISOString(),
53
+ event: String(event || "").trim() || "unknown",
54
+ ...baseDefaults,
55
+ ...payload,
56
+ });
57
+ }
58
+
59
+ function audit(payload = {}) {
60
+ if (!enabled || !paths) return;
61
+ appendJsonLine(paths.auditFile, {
62
+ schema_version: LOOP_EVENT_SCHEMA_VERSION,
63
+ ts: new Date().toISOString(),
64
+ ...baseDefaults,
65
+ ...payload,
66
+ });
67
+ }
68
+
69
+ return {
70
+ emit,
71
+ audit,
72
+ paths,
73
+ };
74
+ }
75
+
76
+ function readRecentLoopSummary(projectRoot, options = {}) {
77
+ if (!projectRoot) return null;
78
+ const { eventsFile } = getLoopObservabilityPaths(projectRoot);
79
+ if (!fs.existsSync(eventsFile)) return null;
80
+
81
+ const maxLines = Number.isFinite(options.maxLines) && options.maxLines > 0
82
+ ? Math.floor(options.maxLines)
83
+ : 400;
84
+
85
+ let rows = [];
86
+ try {
87
+ rows = fs.readFileSync(eventsFile, "utf8")
88
+ .split(/\r?\n/)
89
+ .filter(Boolean)
90
+ .slice(-maxLines)
91
+ .map((line) => {
92
+ try {
93
+ return JSON.parse(line);
94
+ } catch {
95
+ return null;
96
+ }
97
+ })
98
+ .filter(Boolean);
99
+ } catch {
100
+ return null;
101
+ }
102
+
103
+ if (rows.length === 0) return null;
104
+
105
+ let startIndex = 0;
106
+ let endIndex = rows.length;
107
+ let terminalIndex = -1;
108
+ for (let i = rows.length - 1; i >= 0; i -= 1) {
109
+ if (rows[i] && rows[i].event === "loop_terminal") {
110
+ terminalIndex = i;
111
+ endIndex = i + 1;
112
+ break;
113
+ }
114
+ }
115
+ if (terminalIndex >= 0) {
116
+ for (let i = terminalIndex - 1; i >= 0; i -= 1) {
117
+ if (rows[i] && rows[i].event === "loop_terminal") {
118
+ startIndex = i + 1;
119
+ break;
120
+ }
121
+ }
122
+ }
123
+
124
+ const segment = rows.slice(startIndex, endIndex);
125
+ if (segment.length === 0) return null;
126
+
127
+ const toolCounts = new Map();
128
+ const summary = {
129
+ status: terminalIndex >= 0 ? "completed" : "in_progress",
130
+ event_count: segment.length,
131
+ model_calls: 0,
132
+ rounds: 0,
133
+ tool_calls: 0,
134
+ input_tokens: 0,
135
+ output_tokens: 0,
136
+ cache_read_tokens: 0,
137
+ cache_creation_tokens: 0,
138
+ total_tokens: 0,
139
+ total_latency_ms: 0,
140
+ first_token_ms: 0,
141
+ terminal_reason: "",
142
+ started_at: "",
143
+ ended_at: "",
144
+ tools: [],
145
+ };
146
+
147
+ for (const row of segment) {
148
+ if (!summary.started_at && row.ts) summary.started_at = String(row.ts);
149
+ if (row.ts) summary.ended_at = String(row.ts);
150
+ if (row.event === "model_call") {
151
+ summary.model_calls += 1;
152
+ summary.rounds = Math.max(summary.rounds, Number(row.round) || 0);
153
+ summary.input_tokens += Number(row.input_tokens) || 0;
154
+ summary.output_tokens += Number(row.output_tokens) || 0;
155
+ summary.cache_read_tokens += Number(row.cache_read_tokens) || 0;
156
+ summary.cache_creation_tokens += Number(row.cache_creation_tokens) || 0;
157
+ summary.total_latency_ms += Number(row.latency_ms) || 0;
158
+ summary.first_token_ms += Number(row.first_token_ms) || 0;
159
+ } else if (row.event === "tool_call") {
160
+ summary.tool_calls += 1;
161
+ const name = String(row.tool_name || "").trim() || "unknown";
162
+ toolCounts.set(name, (toolCounts.get(name) || 0) + 1);
163
+ } else if (row.event === "loop_terminal") {
164
+ summary.terminal_reason = String(row.terminal_reason || "").trim();
165
+ if ((Number(row.rounds) || 0) > 0) summary.rounds = Number(row.rounds) || summary.rounds;
166
+ if ((Number(row.tool_calls) || 0) >= 0) summary.tool_calls = Number(row.tool_calls) || summary.tool_calls;
167
+ if ((Number(row.total_tokens) || 0) > 0) summary.total_tokens = Number(row.total_tokens) || 0;
168
+ if ((Number(row.total_latency_ms) || 0) > 0) summary.total_latency_ms = Number(row.total_latency_ms) || 0;
169
+ }
170
+ }
171
+
172
+ if (summary.total_tokens <= 0) {
173
+ summary.total_tokens = summary.input_tokens + summary.output_tokens;
174
+ }
175
+
176
+ summary.tools = Array.from(toolCounts.entries())
177
+ .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
178
+ .map(([name, count]) => ({ name, count }));
179
+
180
+ return summary;
181
+ }
182
+
183
+ module.exports = {
184
+ LOOP_EVENT_SCHEMA_VERSION,
185
+ appendShadowDiff,
186
+ getLoopObservabilityPaths,
187
+ getShadowObservabilityPaths,
188
+ createLoopObserver,
189
+ readRecentLoopSummary,
190
+ };
@@ -0,0 +1,457 @@
1
+ "use strict";
2
+
3
+ const { executeControllerTool } = require("./controllerToolExecutor");
4
+ const { createLoopObserver } = require("./loopObservability");
5
+
6
+ const DEFAULT_LOOP_OPTIONS = {
7
+ enabled: false,
8
+ maxRounds: 3,
9
+ maxToolCalls: 3,
10
+ maxToolErrors: 2,
11
+ maxPromptChars: 12000,
12
+ };
13
+
14
+ const TERMINAL_REASONS = Object.freeze({
15
+ FINAL_ANSWER: "final_answer",
16
+ BUDGET_EXCEEDED: "budget_exceeded",
17
+ TOOL_FAILURE: "tool_failure",
18
+ USER_CANCEL: "user_cancel",
19
+ PROVIDER_ERROR: "provider_error",
20
+ });
21
+
22
+ const FALLBACK_USED_VALUES = Object.freeze({
23
+ NONE: "none",
24
+ ASSISTANT_CALL: "assistant_call",
25
+ LEGACY_ROUTER: "legacy_router",
26
+ HELPER_AGENT: "helper_agent",
27
+ });
28
+
29
+ function normalizeTerminalReason(value) {
30
+ const raw = String(value || "").trim();
31
+ if (!raw) return TERMINAL_REASONS.FINAL_ANSWER;
32
+ if (Object.values(TERMINAL_REASONS).includes(raw)) return raw;
33
+ return TERMINAL_REASONS.FINAL_ANSWER;
34
+ }
35
+
36
+ function normalizeFallbackUsed(value) {
37
+ const raw = String(value || "").trim();
38
+ if (!raw) return FALLBACK_USED_VALUES.NONE;
39
+ if (Object.values(FALLBACK_USED_VALUES).includes(raw)) return raw;
40
+ return FALLBACK_USED_VALUES.NONE;
41
+ }
42
+
43
+ function toNonNegativeInt(value) {
44
+ const num = Number(value);
45
+ if (!Number.isFinite(num) || num < 0) return 0;
46
+ return Math.floor(num);
47
+ }
48
+
49
+ function extractModelMetrics(result) {
50
+ const meta = result && result.meta && typeof result.meta === "object" ? result.meta : null;
51
+ const payloadMeta = result && result.payload && typeof result.payload === "object"
52
+ && result.payload.meta && typeof result.payload.meta === "object"
53
+ ? result.payload.meta
54
+ : null;
55
+ const source = { ...(payloadMeta || {}), ...(meta || {}) };
56
+ return {
57
+ input_tokens: toNonNegativeInt(source.input_tokens),
58
+ output_tokens: toNonNegativeInt(source.output_tokens),
59
+ cache_read_tokens: toNonNegativeInt(source.cache_read_tokens),
60
+ cache_creation_tokens: toNonNegativeInt(source.cache_creation_tokens),
61
+ latency_ms: toNonNegativeInt(source.latency_ms),
62
+ first_token_ms: toNonNegativeInt(source.first_token_ms),
63
+ stop_reason: String(source.stop_reason || "").trim(),
64
+ };
65
+ }
66
+
67
+ function normalizePositiveInt(value, fallback) {
68
+ const num = Number.parseInt(value, 10);
69
+ if (Number.isFinite(num) && num > 0) return num;
70
+ return fallback;
71
+ }
72
+
73
+ function resolveLoopRuntimeOptions(env = process.env) {
74
+ const mode = String(env.UFOO_AGENT_RUNTIME_MODE || env.UFOO_AGENT_LOOP_MODE || "").trim().toLowerCase();
75
+ const enabled = mode === "loop" || String(env.UFOO_AGENT_ENABLE_LOOP || "").trim() === "1";
76
+ return {
77
+ enabled,
78
+ maxRounds: normalizePositiveInt(env.UFOO_AGENT_LOOP_MAX_ROUNDS, DEFAULT_LOOP_OPTIONS.maxRounds),
79
+ maxToolCalls: normalizePositiveInt(env.UFOO_AGENT_LOOP_MAX_TOOL_CALLS, DEFAULT_LOOP_OPTIONS.maxToolCalls),
80
+ maxToolErrors: normalizePositiveInt(env.UFOO_AGENT_LOOP_MAX_TOOL_ERRORS, DEFAULT_LOOP_OPTIONS.maxToolErrors),
81
+ maxPromptChars: normalizePositiveInt(env.UFOO_AGENT_LOOP_MAX_PROMPT_CHARS, DEFAULT_LOOP_OPTIONS.maxPromptChars),
82
+ };
83
+ }
84
+
85
+ function normalizePayload(payload) {
86
+ if (!payload || typeof payload !== "object") {
87
+ return { reply: "", dispatch: [], ops: [], done: true };
88
+ }
89
+ return {
90
+ ...payload,
91
+ reply: typeof payload.reply === "string" ? payload.reply : "",
92
+ dispatch: Array.isArray(payload.dispatch) ? payload.dispatch : [],
93
+ ops: Array.isArray(payload.ops) ? payload.ops : [],
94
+ done: payload.done !== false,
95
+ };
96
+ }
97
+
98
+ function buildLoopContinuationPrompt({
99
+ originalPrompt,
100
+ toolResults,
101
+ lastReply,
102
+ loopState,
103
+ }) {
104
+ const lines = [];
105
+ lines.push(String(originalPrompt || ""));
106
+ lines.push("");
107
+ if (lastReply) {
108
+ lines.push("Previous draft reply:");
109
+ lines.push(String(lastReply || ""));
110
+ lines.push("");
111
+ }
112
+ lines.push("Controller loop state (JSON):");
113
+ lines.push(JSON.stringify(loopState, null, 2));
114
+ lines.push("");
115
+ lines.push("Controller tool results so far (JSON):");
116
+ lines.push(JSON.stringify(toolResults, null, 2));
117
+ lines.push("");
118
+ lines.push("Use these results to decide the next tool_call or final JSON response.");
119
+ return lines.join("\n");
120
+ }
121
+
122
+ async function finalizeLoopRun({
123
+ projectRoot,
124
+ payload,
125
+ processManager,
126
+ dispatchMessages,
127
+ handleOps,
128
+ markPending,
129
+ finalizeLocally = true,
130
+ }) {
131
+ if (finalizeLocally === false) {
132
+ return { ok: true, payload, opsResults: [] };
133
+ }
134
+
135
+ const dispatch = Array.isArray(payload.dispatch) ? payload.dispatch : [];
136
+ const ops = Array.isArray(payload.ops) ? payload.ops : [];
137
+
138
+ for (const item of dispatch) {
139
+ if (item && item.target && item.target !== "broadcast" && typeof markPending === "function") {
140
+ markPending(item.target);
141
+ }
142
+ }
143
+
144
+ if (typeof dispatchMessages === "function") {
145
+ await dispatchMessages(projectRoot, dispatch);
146
+ }
147
+
148
+ const opsResults = typeof handleOps === "function"
149
+ ? await handleOps(projectRoot, ops, processManager || null)
150
+ : [];
151
+
152
+ return { ok: true, payload, opsResults };
153
+ }
154
+
155
+ function buildTerminalPayload(reason, lastPayload, rounds, toolCalls, toolErrors, totals = {}) {
156
+ const payload = normalizePayload(lastPayload);
157
+ const canonicalReason = normalizeTerminalReason(reason);
158
+ if (!payload.reply) {
159
+ payload.reply = `Controller loop stopped: ${canonicalReason}.`;
160
+ }
161
+ payload.dispatch = [];
162
+ payload.ops = [];
163
+ payload.loop = {
164
+ terminal_reason: canonicalReason,
165
+ rounds,
166
+ tool_calls: toolCalls,
167
+ tool_errors: toolErrors,
168
+ fallback_used: normalizeFallbackUsed(totals.fallback_used),
169
+ total_tokens: toNonNegativeInt(totals.total_tokens),
170
+ total_latency_ms: toNonNegativeInt(totals.total_latency_ms),
171
+ };
172
+ return payload;
173
+ }
174
+
175
+ async function runPromptWithControllerLoop({
176
+ projectRoot,
177
+ prompt,
178
+ provider,
179
+ model,
180
+ processManager = null,
181
+ runUfooAgent,
182
+ dispatchMessages,
183
+ handleOps,
184
+ ackBus,
185
+ markPending = () => {},
186
+ log = () => {},
187
+ ufooAgentOptions = {},
188
+ finalizeLocally = true,
189
+ loopRuntime = DEFAULT_LOOP_OPTIONS,
190
+ observer: providedObserver = null,
191
+ observabilityDefaults = {},
192
+ now = () => Date.now(),
193
+ isCancelled = null,
194
+ }) {
195
+ const options = { ...DEFAULT_LOOP_OPTIONS, ...(loopRuntime || {}) };
196
+ const observer = providedObserver || createLoopObserver({
197
+ projectRoot,
198
+ enabled: options.enabled !== false,
199
+ defaults: observabilityDefaults,
200
+ });
201
+
202
+ let currentPrompt = String(prompt || "");
203
+ let lastPayload = null;
204
+ let toolCalls = 0;
205
+ let toolErrors = 0;
206
+ let totalTokens = 0;
207
+ let totalLatencyMs = 0;
208
+ const toolResults = [];
209
+
210
+ const checkCancellation = () => {
211
+ if (typeof isCancelled !== "function") return false;
212
+ try {
213
+ return isCancelled() === true;
214
+ } catch {
215
+ return false;
216
+ }
217
+ };
218
+
219
+ const totals = () => ({
220
+ fallback_used: FALLBACK_USED_VALUES.NONE,
221
+ total_tokens: totalTokens,
222
+ total_latency_ms: totalLatencyMs,
223
+ });
224
+
225
+ const terminate = (reason, payloadBase, roundsCount) => {
226
+ const finalPayload = buildTerminalPayload(
227
+ reason,
228
+ payloadBase,
229
+ roundsCount,
230
+ toolCalls,
231
+ toolErrors,
232
+ totals()
233
+ );
234
+ observer.emit("loop_terminal", finalPayload.loop);
235
+ return finalPayload;
236
+ };
237
+
238
+ for (let round = 1; round <= options.maxRounds; round += 1) {
239
+ if (checkCancellation()) {
240
+ const payload = terminate(TERMINAL_REASONS.USER_CANCEL, lastPayload, round - 1);
241
+ return finalizeLoopRun({
242
+ projectRoot,
243
+ payload,
244
+ processManager,
245
+ dispatchMessages,
246
+ handleOps,
247
+ markPending,
248
+ finalizeLocally,
249
+ });
250
+ }
251
+
252
+ if (currentPrompt.length > options.maxPromptChars) {
253
+ const payload = terminate(TERMINAL_REASONS.BUDGET_EXCEEDED, lastPayload, round - 1);
254
+ return finalizeLoopRun({
255
+ projectRoot,
256
+ payload,
257
+ processManager,
258
+ dispatchMessages,
259
+ handleOps,
260
+ markPending,
261
+ finalizeLocally,
262
+ });
263
+ }
264
+
265
+ const roundStartedAt = now();
266
+ observer.emit("model_call_started", {
267
+ round,
268
+ provider: String(provider || ""),
269
+ model: String(model || ""),
270
+ prompt_chars: currentPrompt.length,
271
+ });
272
+
273
+ const result = await runUfooAgent({
274
+ projectRoot,
275
+ prompt: currentPrompt,
276
+ provider,
277
+ model,
278
+ ...ufooAgentOptions,
279
+ loopRuntime: {
280
+ enabled: true,
281
+ round,
282
+ maxRounds: options.maxRounds,
283
+ maxToolCalls: options.maxToolCalls,
284
+ remainingToolCalls: Math.max(options.maxToolCalls - toolCalls, 0),
285
+ },
286
+ });
287
+
288
+ const metrics = extractModelMetrics(result);
289
+ const modelLatency = metrics.latency_ms > 0 ? metrics.latency_ms : Math.max(0, now() - roundStartedAt);
290
+ totalTokens += metrics.input_tokens + metrics.output_tokens;
291
+ totalLatencyMs += modelLatency;
292
+
293
+ const toolCall = result && result.payload && typeof result.payload === "object"
294
+ && result.payload.tool_call && typeof result.payload.tool_call === "object"
295
+ ? result.payload.tool_call
296
+ : null;
297
+
298
+ observer.emit("model_call", {
299
+ round,
300
+ provider: String(provider || ""),
301
+ model: String(model || ""),
302
+ ok: result && result.ok === true,
303
+ input_tokens: metrics.input_tokens,
304
+ output_tokens: metrics.output_tokens,
305
+ cache_read_tokens: metrics.cache_read_tokens,
306
+ cache_creation_tokens: metrics.cache_creation_tokens,
307
+ latency_ms: modelLatency,
308
+ first_token_ms: metrics.first_token_ms,
309
+ tool_call_count: toolCall ? 1 : 0,
310
+ stop_reason: metrics.stop_reason,
311
+ error: result && result.ok === false ? String(result.error || "") : "",
312
+ });
313
+ observer.emit("model_call_finished", {
314
+ round,
315
+ ok: result && result.ok === true,
316
+ error: result && result.ok === false ? String(result.error || "") : "",
317
+ });
318
+
319
+ if (!result || result.ok !== true) {
320
+ const payload = terminate(TERMINAL_REASONS.PROVIDER_ERROR, lastPayload, round);
321
+ return {
322
+ ok: false,
323
+ error: result && result.error ? result.error : "ufoo-agent loop failed",
324
+ payload,
325
+ };
326
+ }
327
+
328
+ const payload = normalizePayload(result.payload);
329
+ lastPayload = payload;
330
+
331
+ if (!toolCall) {
332
+ const finalPayload = {
333
+ ...payload,
334
+ loop: {
335
+ terminal_reason: TERMINAL_REASONS.FINAL_ANSWER,
336
+ rounds: round,
337
+ tool_calls: toolCalls,
338
+ tool_errors: toolErrors,
339
+ fallback_used: FALLBACK_USED_VALUES.NONE,
340
+ total_tokens: totalTokens,
341
+ total_latency_ms: totalLatencyMs,
342
+ },
343
+ };
344
+ observer.emit("loop_terminal", finalPayload.loop);
345
+ return finalizeLoopRun({
346
+ projectRoot,
347
+ payload: finalPayload,
348
+ processManager,
349
+ dispatchMessages,
350
+ handleOps,
351
+ markPending,
352
+ finalizeLocally,
353
+ });
354
+ }
355
+
356
+ if (toolCalls >= options.maxToolCalls) {
357
+ const finalPayload = terminate(TERMINAL_REASONS.BUDGET_EXCEEDED, payload, round);
358
+ return finalizeLoopRun({
359
+ projectRoot,
360
+ payload: finalPayload,
361
+ processManager,
362
+ dispatchMessages,
363
+ handleOps,
364
+ markPending,
365
+ finalizeLocally,
366
+ });
367
+ }
368
+
369
+ toolCalls += 1;
370
+ const toolStartedAt = now();
371
+ const toolResult = await executeControllerTool({
372
+ projectRoot,
373
+ subscriber: "ufoo-agent",
374
+ processManager,
375
+ dispatchMessages,
376
+ handleOps,
377
+ ackBus,
378
+ markPending,
379
+ observer,
380
+ turnId: `loop-round-${round}`,
381
+ }, toolCall);
382
+ const toolDuration = Math.max(0, now() - toolStartedAt);
383
+
384
+ let toolResultSize = 0;
385
+ try {
386
+ toolResultSize = toolResult && toolResult.result !== undefined
387
+ ? JSON.stringify(toolResult.result).length
388
+ : 0;
389
+ } catch {
390
+ toolResultSize = 0;
391
+ }
392
+
393
+ observer.emit("tool_call", {
394
+ round,
395
+ tool_name: String(toolResult && toolResult.name ? toolResult.name : toolCall.name || ""),
396
+ tool_call_id: String(toolResult && toolResult.tool_call_id ? toolResult.tool_call_id : ""),
397
+ turn_id: toolResult && toolResult.turn_id ? String(toolResult.turn_id) : `loop-round-${round}`,
398
+ duration_ms: toolDuration,
399
+ result_size: toolResultSize,
400
+ retry_count: 0,
401
+ final_status: toolResult && toolResult.ok === true ? "ok" : "error",
402
+ });
403
+
404
+ if (!toolResult.ok) {
405
+ toolErrors += 1;
406
+ }
407
+ toolResults.push(toolResult);
408
+
409
+ if (toolErrors >= options.maxToolErrors) {
410
+ const finalPayload = terminate(TERMINAL_REASONS.TOOL_FAILURE, payload, round);
411
+ return finalizeLoopRun({
412
+ projectRoot,
413
+ payload: finalPayload,
414
+ processManager,
415
+ dispatchMessages,
416
+ handleOps,
417
+ markPending,
418
+ finalizeLocally,
419
+ });
420
+ }
421
+
422
+ currentPrompt = buildLoopContinuationPrompt({
423
+ originalPrompt: prompt,
424
+ toolResults,
425
+ lastReply: payload.reply,
426
+ loopState: {
427
+ round,
428
+ max_rounds: options.maxRounds,
429
+ tool_calls_used: toolCalls,
430
+ tool_calls_remaining: Math.max(options.maxToolCalls - toolCalls, 0),
431
+ tool_errors: toolErrors,
432
+ },
433
+ });
434
+ }
435
+
436
+ const payload = terminate(TERMINAL_REASONS.BUDGET_EXCEEDED, lastPayload, options.maxRounds);
437
+ return finalizeLoopRun({
438
+ projectRoot,
439
+ payload,
440
+ processManager,
441
+ dispatchMessages,
442
+ handleOps,
443
+ markPending,
444
+ finalizeLocally,
445
+ });
446
+ }
447
+
448
+ module.exports = {
449
+ DEFAULT_LOOP_OPTIONS,
450
+ FALLBACK_USED_VALUES,
451
+ TERMINAL_REASONS,
452
+ buildLoopContinuationPrompt,
453
+ normalizeFallbackUsed,
454
+ normalizeTerminalReason,
455
+ resolveLoopRuntimeOptions,
456
+ runPromptWithControllerLoop,
457
+ };