ubume 0.1.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 (182) hide show
  1. package/README.md +88 -0
  2. package/bin/codexa-local-harness-bridge.js +3 -0
  3. package/bin/codexa.js +28 -0
  4. package/bin/ubume-local-harness-bridge.js +325 -0
  5. package/bin/ubume.js +398 -0
  6. package/package.json +66 -0
  7. package/src/app.tsx +5759 -0
  8. package/src/commands/handler.ts +889 -0
  9. package/src/config/appVersion.ts +69 -0
  10. package/src/config/buildInfo.ts +3 -0
  11. package/src/config/launchArgs.ts +196 -0
  12. package/src/config/layeredConfig.ts +853 -0
  13. package/src/config/legacyEnv.ts +16 -0
  14. package/src/config/persistence.ts +377 -0
  15. package/src/config/runtimeConfig.ts +558 -0
  16. package/src/config/settings.ts +405 -0
  17. package/src/config/toml-serialize.ts +109 -0
  18. package/src/config/trustStore.ts +84 -0
  19. package/src/config/updateCheckCache.ts +85 -0
  20. package/src/core/README.md +55 -0
  21. package/src/core/agent/loop.ts +464 -0
  22. package/src/core/agent/protocol.ts +345 -0
  23. package/src/core/agent/tools.ts +423 -0
  24. package/src/core/auth/codexAuth.ts +359 -0
  25. package/src/core/codex/codexExecArgs.ts +166 -0
  26. package/src/core/codex/codexLaunch.ts +163 -0
  27. package/src/core/codex/codexPrompt.ts +429 -0
  28. package/src/core/debug/inputDebug.ts +51 -0
  29. package/src/core/debug/localStreamDebug.ts +50 -0
  30. package/src/core/debug/modelStateDebug.ts +35 -0
  31. package/src/core/executables/antigravityExecutable.ts +48 -0
  32. package/src/core/executables/claudeExecutable.ts +63 -0
  33. package/src/core/executables/codexExecutable.ts +160 -0
  34. package/src/core/executables/executableResolver.ts +164 -0
  35. package/src/core/executables/geminiExecutable.ts +78 -0
  36. package/src/core/models/codexCapabilities.ts +97 -0
  37. package/src/core/models/codexModelCapabilities.ts +624 -0
  38. package/src/core/models/codexModelsCacheSeed.ts +153 -0
  39. package/src/core/models/modelSpecs.ts +303 -0
  40. package/src/core/models/providerModelCache.ts +94 -0
  41. package/src/core/perf/profiler.ts +125 -0
  42. package/src/core/perf/renderDebug.ts +398 -0
  43. package/src/core/process/CommandRunner.ts +280 -0
  44. package/src/core/process/processValidation.ts +111 -0
  45. package/src/core/providerLauncher/launcher.ts +220 -0
  46. package/src/core/providerLauncher/registry.ts +354 -0
  47. package/src/core/providerLauncher/types.ts +95 -0
  48. package/src/core/providerLauncher/workspaceConfig.ts +487 -0
  49. package/src/core/providerRuntime/anthropic.ts +580 -0
  50. package/src/core/providerRuntime/antigravity.ts +500 -0
  51. package/src/core/providerRuntime/capabilityProfile.ts +383 -0
  52. package/src/core/providerRuntime/claudeCodeDiscovery.ts +724 -0
  53. package/src/core/providerRuntime/claudeCodeDiscoveryDebug.ts +55 -0
  54. package/src/core/providerRuntime/codexaCupy.ts +97 -0
  55. package/src/core/providerRuntime/codexaNative.ts +425 -0
  56. package/src/core/providerRuntime/contextMetadata.ts +397 -0
  57. package/src/core/providerRuntime/gemini.ts +789 -0
  58. package/src/core/providerRuntime/lmstudio.ts +118 -0
  59. package/src/core/providerRuntime/local.ts +770 -0
  60. package/src/core/providerRuntime/localHarness/runtime.ts +1090 -0
  61. package/src/core/providerRuntime/localOutputBudget.ts +17 -0
  62. package/src/core/providerRuntime/mistralVibe.ts +667 -0
  63. package/src/core/providerRuntime/models.ts +175 -0
  64. package/src/core/providerRuntime/reasoning.ts +20 -0
  65. package/src/core/providerRuntime/registry.ts +286 -0
  66. package/src/core/providerRuntime/types.ts +142 -0
  67. package/src/core/providerRuntime/unsloth.ts +216 -0
  68. package/src/core/providers/codexJsonStream.ts +305 -0
  69. package/src/core/providers/codexSubprocess.ts +378 -0
  70. package/src/core/providers/codexTranscript.ts +695 -0
  71. package/src/core/providers/openaiNative.ts +13 -0
  72. package/src/core/providers/registry.ts +21 -0
  73. package/src/core/providers/types.ts +91 -0
  74. package/src/core/shared/attachments.ts +101 -0
  75. package/src/core/shared/cleanupFastFail.ts +67 -0
  76. package/src/core/shared/clipboard.ts +24 -0
  77. package/src/core/shared/clipboardImage.ts +111 -0
  78. package/src/core/shared/githubDiagnostics.ts +222 -0
  79. package/src/core/shared/hollowResponseFormat.ts +39 -0
  80. package/src/core/terminal/clearFrameBoundary.ts +852 -0
  81. package/src/core/terminal/frameLock.ts +110 -0
  82. package/src/core/terminal/inkRenderReset.ts +123 -0
  83. package/src/core/terminal/startupClear.ts +20 -0
  84. package/src/core/terminal/terminalCapabilities.ts +100 -0
  85. package/src/core/terminal/terminalControl.ts +169 -0
  86. package/src/core/terminal/terminalSanitize.ts +147 -0
  87. package/src/core/terminal/terminalTitle.ts +400 -0
  88. package/src/core/version/channel.ts +27 -0
  89. package/src/core/version/packageManager.ts +119 -0
  90. package/src/core/version/updateCheck.ts +203 -0
  91. package/src/core/workspace/appData.ts +107 -0
  92. package/src/core/workspace/conversationStore.ts +335 -0
  93. package/src/core/workspace/launchContext.ts +259 -0
  94. package/src/core/workspace/planStorage.ts +135 -0
  95. package/src/core/workspace/projectInstructions.ts +54 -0
  96. package/src/core/workspace/scratchDir.ts +64 -0
  97. package/src/core/workspace/workspaceActivity.ts +384 -0
  98. package/src/core/workspace/workspaceGuard.ts +377 -0
  99. package/src/core/workspace/workspaceRoot.ts +47 -0
  100. package/src/exec.ts +73 -0
  101. package/src/headless/execArgs.ts +296 -0
  102. package/src/headless/execRunner.ts +304 -0
  103. package/src/index.tsx +270 -0
  104. package/src/legacyEnvBootstrap.ts +5 -0
  105. package/src/session/appSession.ts +771 -0
  106. package/src/session/chatLifecycle.ts +994 -0
  107. package/src/session/conversation.ts +107 -0
  108. package/src/session/liveRenderScheduler.ts +214 -0
  109. package/src/session/persistedResponse.ts +93 -0
  110. package/src/session/planFlow.ts +159 -0
  111. package/src/session/planTranscript.ts +19 -0
  112. package/src/session/promptRunSchedule.ts +26 -0
  113. package/src/session/types.ts +234 -0
  114. package/src/test/runtimeTestUtils.ts +14 -0
  115. package/src/types/react-dom.d.ts +3 -0
  116. package/src/ui/chrome/ActivityBars.tsx +68 -0
  117. package/src/ui/chrome/ActivityIndicator.tsx +58 -0
  118. package/src/ui/chrome/AnimatedStatusText.tsx +69 -0
  119. package/src/ui/chrome/AppShell.tsx +474 -0
  120. package/src/ui/chrome/BottomComposer.tsx +1135 -0
  121. package/src/ui/chrome/DashCard.tsx +82 -0
  122. package/src/ui/chrome/RunFooter.tsx +65 -0
  123. package/src/ui/chrome/RuntimeStatusBar.tsx +108 -0
  124. package/src/ui/chrome/Spinner.tsx +25 -0
  125. package/src/ui/chrome/TopHeader.tsx +439 -0
  126. package/src/ui/chrome/UpdateAvailableCard.tsx +42 -0
  127. package/src/ui/chrome/busyStatusAnimation.ts +11 -0
  128. package/src/ui/input/commandNormalize.ts +66 -0
  129. package/src/ui/input/focus.ts +73 -0
  130. package/src/ui/input/imageAttachments.ts +18 -0
  131. package/src/ui/input/inputBuffer.ts +203 -0
  132. package/src/ui/input/pastedContent.ts +75 -0
  133. package/src/ui/input/rawArrowKeys.ts +28 -0
  134. package/src/ui/input/slashCommands.ts +43 -0
  135. package/src/ui/input/useStdinRawModeLease.ts +23 -0
  136. package/src/ui/layout.ts +560 -0
  137. package/src/ui/panels/AttachmentImportPanel.tsx +131 -0
  138. package/src/ui/panels/AuthPanel.tsx +149 -0
  139. package/src/ui/panels/BackendPicker.tsx +28 -0
  140. package/src/ui/panels/ModePicker.tsx +31 -0
  141. package/src/ui/panels/ModelPicker.tsx +31 -0
  142. package/src/ui/panels/ModelPickerScreen.tsx +761 -0
  143. package/src/ui/panels/ModelReasoningPicker.tsx +458 -0
  144. package/src/ui/panels/Panel.tsx +51 -0
  145. package/src/ui/panels/PermissionsPanel.tsx +78 -0
  146. package/src/ui/panels/PlanActionPicker.tsx +187 -0
  147. package/src/ui/panels/ProviderPicker.tsx +753 -0
  148. package/src/ui/panels/ProviderSetupPrompt.tsx +52 -0
  149. package/src/ui/panels/ReasoningPicker.tsx +46 -0
  150. package/src/ui/panels/ResumePicker.tsx +90 -0
  151. package/src/ui/panels/SelectionPanel.tsx +138 -0
  152. package/src/ui/panels/SettingsPanel.tsx +156 -0
  153. package/src/ui/panels/TextEntryPanel.tsx +139 -0
  154. package/src/ui/panels/ThemePicker.tsx +32 -0
  155. package/src/ui/panels/ToolApprovalPanel.tsx +46 -0
  156. package/src/ui/panels/UpdatePromptPanel.tsx +236 -0
  157. package/src/ui/panels/responsivePickerViewport.ts +64 -0
  158. package/src/ui/render/Markdown.tsx +331 -0
  159. package/src/ui/render/diffRenderer.ts +116 -0
  160. package/src/ui/render/logoVariants.ts +113 -0
  161. package/src/ui/render/modeDisplay.ts +52 -0
  162. package/src/ui/render/outputPipeline.ts +64 -0
  163. package/src/ui/render/runtimeDisplay.ts +128 -0
  164. package/src/ui/render/terminalAnswerFormat.ts +128 -0
  165. package/src/ui/render/textLayout.ts +392 -0
  166. package/src/ui/theme.tsx +274 -0
  167. package/src/ui/themeFlow.ts +41 -0
  168. package/src/ui/timeline/ActionRequiredBlock.tsx +38 -0
  169. package/src/ui/timeline/AgentBlock.tsx +130 -0
  170. package/src/ui/timeline/StaticIntroItem.tsx +54 -0
  171. package/src/ui/timeline/ThinkingBlock.tsx +100 -0
  172. package/src/ui/timeline/Timeline.tsx +1410 -0
  173. package/src/ui/timeline/TranscriptShell.tsx +302 -0
  174. package/src/ui/timeline/TurnGroup.tsx +673 -0
  175. package/src/ui/timeline/layoutListWindow.ts +145 -0
  176. package/src/ui/timeline/liveViewportWindow.ts +68 -0
  177. package/src/ui/timeline/progressEntries.ts +156 -0
  178. package/src/ui/timeline/runActivityView.ts +37 -0
  179. package/src/ui/timeline/staticTranscriptCache.ts +174 -0
  180. package/src/ui/timeline/streamCoalesce.ts +53 -0
  181. package/src/ui/timeline/timelineMeasure.ts +3273 -0
  182. package/src/ui/useThrottledValue.ts +31 -0
@@ -0,0 +1,55 @@
1
+ # `src/core`
2
+
3
+ Non-UI runtime logic for Ubume: launching backends, talking to provider CLIs,
4
+ terminal I/O, workspace resolution, and supporting utilities. UI lives in `src/ui`,
5
+ app state in `src/session`, config in `src/config`. The top level of `src/core` is
6
+ folders-only — every file lives in a domain folder so the directory stays scannable.
7
+
8
+ ## Folder map
9
+
10
+ | Folder | Responsibility |
11
+ | --- | --- |
12
+ | `providers/` | **Low-level Codex subprocess I/O.** Spawns the `codex` binary and parses its output (`codexSubprocess`, `codexJsonStream`, `codexTranscript`), plus the backend registry/types and the `openaiNative` stub. |
13
+ | `providerRuntime/` | **Multi-provider runtimes + discovery.** One runtime per provider (`anthropic`, `gemini`, `local`, `antigravity`) behind a shared interface, plus routing (`registry`), model/metadata helpers (`models`, `capabilityProfile`, `contextMetadata`, `reasoning`) and Claude Code discovery. |
14
+ | `providerLauncher/` | **Workspace provider config + CLI launching.** Which provider is active per workspace (`workspaceConfig`), provider UI state (`registry`), and spawning provider CLIs (`launcher`). |
15
+ | `codex/` | Codex CLI launch/prompt assembly: `codexExecArgs`, `codexLaunch`, `codexPrompt`. |
16
+ | `models/` | Codex CLI capability discovery (`codexCapabilities`, `codexModelCapabilities`) and the legacy model-spec service (`modelSpecs`). |
17
+ | `executables/` | Resolve external CLI binaries with PATH/env handling (`executableResolver` + per-CLI resolvers). |
18
+ | `auth/` | Codex auth status probing. |
19
+ | `process/` | Generic process spawning (`CommandRunner`) and executable-path validation. |
20
+ | `terminal/` | Terminal I/O: ANSI sanitize, raw mode / cursor, title sequences, capability detection, and the `/clear` + resize repaint boundary (`clearFrameBoundary`, `inkRenderReset`). |
21
+ | `workspace/` | Workspace resolution and state: `workspaceRoot`, `workspaceGuard`, `workspaceActivity`, `projectInstructions`, `planStorage`, `launchContext`. |
22
+ | `version/` | Build channel / version branding (`channel`) and update checking (`updateCheck`). |
23
+ | `shared/` | Small cross-cutting utilities: `clipboard`, `cleanupFastFail`, `githubDiagnostics`, `attachments`, `hollowResponseFormat`. |
24
+ | `perf/` | Performance + render instrumentation (`profiler`, `renderDebug`). |
25
+ | `debug/` | Dev-only tracing helpers (`inputDebug`). |
26
+
27
+ ## The three provider layers
28
+
29
+ `providers/`, `providerRuntime/`, and `providerLauncher/` have similar names but are
30
+ distinct layers — they are **not** duplicates:
31
+
32
+ ```
33
+ providerLauncher/ which provider is active for this workspace + how to spawn its CLI
34
+
35
+ providerRuntime/ per-provider runtimes (anthropic/gemini/local/antigravity),
36
+ │ routing, model discovery, capability/context metadata
37
+
38
+ providers/ low-level Codex subprocess I/O + output parsing
39
+ ```
40
+
41
+ The default Codex backend flows through `providers/`; the other providers are
42
+ implemented as `providerRuntime/` runtimes.
43
+
44
+ ## Debug instrumentation
45
+
46
+ These are intentional, env-gated diagnostics (not dead code) — keep them named clearly:
47
+
48
+ - `debug/inputDebug.ts` — stdin state tracing (`UBUME_DEBUG_INPUT=1`).
49
+ - `debug/localStreamDebug.ts` — privacy-aware Local streaming diagnostics
50
+ (`UBUME_DEBUG_LOCAL_STREAM=1`; add `UBUME_DEBUG_LOCAL_STREAM_CONTENT=1`
51
+ only when response text is safe to record).
52
+ - `perf/renderDebug.ts` — Ink render/flicker tracing (`UBUME_RENDER_DEBUG=1`). Kept in
53
+ `perf/` rather than `debug/` because it is imported widely across the UI.
54
+ - `providerRuntime/claudeCodeDiscoveryDebug.ts` — entry point for the
55
+ `bun run debug:claude-models` script; lives next to the discovery code it exercises.
@@ -0,0 +1,464 @@
1
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import type { BackendRunHandlers } from "../providers/types.js";
4
+ import type { ProviderChatRequest } from "../providerRuntime/types.js";
5
+ import { executeAgentTool, type AgentToolResult } from "./tools.js";
6
+ import {
7
+ parseAgentToolCall,
8
+ serializeToolResult,
9
+ type MalformedOpenAiToolCall,
10
+ type NormalizedAgentToolCall,
11
+ } from "./protocol.js";
12
+
13
+ export interface AgentChatToolCall {
14
+ id: string;
15
+ type: "function";
16
+ function: { name: string; arguments: string };
17
+ }
18
+
19
+ export type AgentChatMessage =
20
+ | { role: "system" | "user"; content: string }
21
+ | { role: "assistant"; content: string | null; reasoning_content?: string; tool_calls?: readonly AgentChatToolCall[] }
22
+ | { role: "tool"; content: string; tool_call_id: string };
23
+
24
+ export interface AgentChatResponse {
25
+ text: string;
26
+ reasoning?: string;
27
+ toolCalls?: readonly NormalizedAgentToolCall[];
28
+ malformedToolCalls?: readonly MalformedOpenAiToolCall[];
29
+ finishReason?: string | null;
30
+ }
31
+
32
+ export interface RunAgentLoopOptions {
33
+ request: ProviderChatRequest;
34
+ handlers: BackendRunHandlers;
35
+ sendMessages: (messages: readonly AgentChatMessage[], turnIndex: number) => Promise<AgentChatResponse>;
36
+ includeSystemPrompt: boolean;
37
+ toolProtocol?: "none" | "text" | "openai";
38
+ signal?: AbortSignal;
39
+ maxConsecutiveNoProgressCalls?: number;
40
+ }
41
+
42
+ const DEFAULT_MAX_CONSECUTIVE_NO_PROGRESS_CALLS = 3;
43
+
44
+ function workspaceSummary(workspaceRoot: string): string {
45
+ const lines = [`Workspace root: ${workspaceRoot}`];
46
+ try {
47
+ const entries = readdirSync(workspaceRoot, { withFileTypes: true })
48
+ .filter((entry) => ![".git", "node_modules", "dist", "build", "coverage"].includes(entry.name))
49
+ .map((entry) => `${entry.name}${entry.isDirectory() ? "/" : ""}`)
50
+ .sort()
51
+ .slice(0, 20);
52
+ if (entries.length > 0) lines.push(`Top-level entries: ${entries.join(", ")}`);
53
+ } catch {
54
+ // Tool-based inspection remains available when a shallow summary is unavailable.
55
+ }
56
+
57
+ try {
58
+ const packageJsonPath = path.join(workspaceRoot, "package.json");
59
+ if (existsSync(packageJsonPath)) {
60
+ const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as Record<string, unknown>;
61
+ const name = typeof packageJson.name === "string" ? packageJson.name : null;
62
+ const description = typeof packageJson.description === "string" ? packageJson.description : null;
63
+ if (name) lines.push(`Package: ${name}${description ? ` - ${description}` : ""}`);
64
+ }
65
+ } catch {
66
+ // A malformed package.json should not prevent a local chat request.
67
+ }
68
+
69
+ return lines.join("\n");
70
+ }
71
+
72
+ function localAgentSystemPrompt(request: ProviderChatRequest, toolProtocol: "none" | "text" | "openai"): string {
73
+ const hasCargoToml = existsSync(path.join(request.workspaceRoot, "Cargo.toml"));
74
+ const planning = request.runIntent === "plan";
75
+ return [
76
+ `You are an autonomous coding assistant running inside this workspace: ${request.workspaceRoot}`,
77
+ "You must inspect files with tools before claiming you cannot see them.",
78
+ "For broad questions about the repository, use the workspace summary below, then inspect with get_workspace_info or list_files before answering when more detail is needed.",
79
+ planning
80
+ ? "PLAN MODE: inspect the repository and return a concrete Markdown implementation plan. Do not write files, apply patches, or run shell commands."
81
+ : "Use tools to create, edit, build, and test when the user asks for workspace changes.",
82
+ planning
83
+ ? null
84
+ : "When the user explicitly asks you to commit, push, or open a pull request, complete those actions with tools when runtime policy permits. Do not replace unfinished authorized work with commands for the user to run.",
85
+ "Keep inspecting and acting until the requested work is complete or a concrete external blocker prevents progress. Never mention internal tool budgets or claim a file, commit, push, or pull request exists unless a tool confirmed it.",
86
+ "Do not ask vague clarification questions when the user's intent has an obvious safe implementation.",
87
+ hasCargoToml
88
+ ? "Rust workspace note: Cargo.toml exists. Prefer src/main.rs for simple binaries, use cargo check for validation, use cargo run for running, and do not use rustc main.rs unless main.rs is truly at the workspace root."
89
+ : null,
90
+ toolProtocol === "text" ? "Use exactly one tool call at a time in this format:" : null,
91
+ toolProtocol === "text" ? '<tool_call>{"name":"read_file","arguments":{"path":"src/index.tsx"}}</tool_call>' : null,
92
+ toolProtocol === "text"
93
+ ? planning
94
+ ? "Available tools: list_files, read_file, get_workspace_info."
95
+ : "Available tools: list_files, read_file, write_file, apply_patch, run_shell, get_workspace_info."
96
+ : toolProtocol === "openai"
97
+ ? "Use the provided API tools when workspace inspection or action is required."
98
+ : "No workspace tools are available for this model.",
99
+ "Summarize changed files and commands run in your final answer.",
100
+ `Workspace summary:\n${workspaceSummary(request.workspaceRoot)}`,
101
+ request.projectInstructions?.content
102
+ ? ["Project instructions:", request.projectInstructions.content].join("\n")
103
+ : null,
104
+ ].filter(Boolean).join("\n\n");
105
+ }
106
+
107
+ function buildInitialMessages(
108
+ request: ProviderChatRequest,
109
+ includeSystemPrompt: boolean,
110
+ toolProtocol: "none" | "text" | "openai",
111
+ ): AgentChatMessage[] {
112
+ const systemPrompt = localAgentSystemPrompt(request, toolProtocol);
113
+ if (includeSystemPrompt) {
114
+ return [
115
+ { role: "system", content: systemPrompt },
116
+ ...(request.conversationHistory ?? []).map((message) => ({ role: message.role, content: message.content })),
117
+ { role: "user", content: request.prompt },
118
+ ];
119
+ }
120
+
121
+ return [
122
+ ...(request.conversationHistory ?? []).map((message) => ({ role: message.role, content: message.content })),
123
+ { role: "user", content: `${systemPrompt}\n\nUser request:\n${request.prompt}` },
124
+ ];
125
+ }
126
+
127
+ function toolActivityCommand(result: Pick<AgentToolResult, "tool" | "path" | "paths" | "command">): string {
128
+ if (result.command) return `${result.tool}: ${result.command}`;
129
+ if (result.path) return `${result.tool}: ${result.path}`;
130
+ if (result.paths && result.paths.length > 0) return `${result.tool}: ${result.paths.join(", ")}`;
131
+ return result.tool;
132
+ }
133
+
134
+ interface ExecutedCommand {
135
+ command: string;
136
+ success: boolean;
137
+ exitCode?: number | null;
138
+ durationMs?: number;
139
+ }
140
+
141
+ interface AgentLoopSummary {
142
+ changedFiles: Set<string>;
143
+ commands: ExecutedCommand[];
144
+ toolResults: AgentToolResult[];
145
+ }
146
+
147
+ function stableJson(value: unknown): string {
148
+ if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
149
+ if (value && typeof value === "object") {
150
+ const record = value as Record<string, unknown>;
151
+ return `{${Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${stableJson(record[key])}`).join(",")}}`;
152
+ }
153
+ return JSON.stringify(value);
154
+ }
155
+
156
+ function toolCallSignature(call: Pick<NormalizedAgentToolCall, "name" | "arguments">): string {
157
+ return `${call.name}:${stableJson(call.arguments)}`;
158
+ }
159
+
160
+ function toolResultFingerprint(result: AgentToolResult): string {
161
+ const { durationMs: _durationMs, ...stableResult } = result;
162
+ return stableJson(stableResult);
163
+ }
164
+
165
+ function recordToolResult(summary: AgentLoopSummary, result: AgentToolResult): void {
166
+ for (const file of result.paths ?? []) {
167
+ if (file) summary.changedFiles.add(file);
168
+ }
169
+ if (result.path && (result.tool === "write_file" || result.tool === "apply_patch")) {
170
+ summary.changedFiles.add(result.path);
171
+ }
172
+ if (result.command) {
173
+ summary.commands.push({
174
+ command: result.command,
175
+ success: result.success,
176
+ exitCode: result.exitCode,
177
+ durationMs: result.durationMs,
178
+ });
179
+ }
180
+ summary.toolResults.push(result);
181
+ }
182
+
183
+ function commandStatus(command: ExecutedCommand): string {
184
+ const status = command.success ? "succeeded" : "failed";
185
+ const exitCode = command.exitCode === undefined ? "" : `, exit ${command.exitCode ?? "n/a"}`;
186
+ return `- ${command.command}: ${status}${exitCode}`;
187
+ }
188
+
189
+ function synthesizeFinalMessage(_request: ProviderChatRequest, summary: AgentLoopSummary, reason: string): string {
190
+ const files = [...summary.changedFiles].sort();
191
+ const commandLines = summary.commands.map(commandStatus);
192
+ return [
193
+ reason,
194
+ "",
195
+ "Files changed:",
196
+ files.length > 0 ? files.map((file) => `- ${file}`).join("\n") : "- None detected",
197
+ "",
198
+ "Commands run:",
199
+ commandLines.length > 0 ? commandLines.join("\n") : "- None",
200
+ ].join("\n").trim();
201
+ }
202
+
203
+ async function requestFinalAnswer(options: RunAgentLoopOptions, messages: AgentChatMessage[], toolCallCount: number, reason: string): Promise<string | null> {
204
+ messages.push({
205
+ role: "user",
206
+ content: [
207
+ reason,
208
+ "Repeated tool calls are no longer changing the result. Stop calling tools and report the exact blocker using only confirmed tool results.",
209
+ "Include files changed and commands run with their outcomes. Do not expose internal loop controls, claim unfinished work is complete, or delegate runnable actions to the user unless the required capability is genuinely unavailable.",
210
+ ].join("\n"),
211
+ });
212
+ const response = await options.sendMessages(messages, toolCallCount);
213
+ if ((response.toolCalls?.length ?? 0) > 0 || (response.malformedToolCalls?.length ?? 0) > 0) return null;
214
+ const parsed = parseAgentToolCall(response.text);
215
+ return parsed.kind === "final" && parsed.text.trim() ? parsed.text.trim() : null;
216
+ }
217
+
218
+ function wireToolCall(call: NormalizedAgentToolCall, id: string): AgentChatToolCall {
219
+ return {
220
+ id,
221
+ type: "function",
222
+ function: {
223
+ name: call.name,
224
+ arguments: call.rawArguments || JSON.stringify(call.arguments),
225
+ },
226
+ };
227
+ }
228
+
229
+ function malformedWireToolCall(call: MalformedOpenAiToolCall, id: string): AgentChatToolCall {
230
+ return {
231
+ id,
232
+ type: "function",
233
+ function: {
234
+ name: call.name ?? "unknown",
235
+ arguments: call.rawArguments || "{}",
236
+ },
237
+ };
238
+ }
239
+
240
+ function appendToolResultMessage(
241
+ messages: AgentChatMessage[],
242
+ native: boolean,
243
+ toolCallId: string,
244
+ result: unknown,
245
+ ): void {
246
+ const content = serializeToolResult(result);
247
+ messages.push(native
248
+ ? { role: "tool", tool_call_id: toolCallId, content }
249
+ : { role: "user", content });
250
+ }
251
+
252
+ export async function runAgentLoop(options: RunAgentLoopOptions): Promise<string> {
253
+ const maxConsecutiveNoProgressCalls = Math.max(
254
+ 1,
255
+ Math.floor(options.maxConsecutiveNoProgressCalls ?? DEFAULT_MAX_CONSECUTIVE_NO_PROGRESS_CALLS),
256
+ );
257
+ const toolProtocol = options.toolProtocol ?? "text";
258
+ const messages = buildInitialMessages(options.request, options.includeSystemPrompt, toolProtocol);
259
+ let toolCallCount = 0;
260
+ const completedToolCallIds = new Set<string>();
261
+ const previousToolResults = new Map<string, string>();
262
+ let consecutiveNoProgressCalls = 0;
263
+ const approvedForRun = new Set<string>();
264
+ const summary: AgentLoopSummary = {
265
+ changedFiles: new Set(),
266
+ commands: [],
267
+ toolResults: [],
268
+ };
269
+
270
+ while (true) {
271
+ if (options.signal?.aborted) {
272
+ throw new Error("Local agent run was canceled.");
273
+ }
274
+
275
+ const response = await options.sendMessages(messages, toolCallCount);
276
+ const structuredCalls = [...(response.toolCalls ?? [])];
277
+ const malformedCalls = [...(response.malformedToolCalls ?? [])];
278
+ const native = structuredCalls.length > 0 || malformedCalls.length > 0;
279
+ let calls = structuredCalls;
280
+ let textMalformed: { error: string; raw: string } | null = null;
281
+
282
+ if (!native) {
283
+ const parsed = parseAgentToolCall(response.text);
284
+ if (parsed.kind === "final") {
285
+ if (response.finishReason === "tool_calls") {
286
+ textMalformed = { error: "Completion ended with finish_reason=tool_calls but contained no tool calls.", raw: response.text };
287
+ } else {
288
+ return parsed.text.trim();
289
+ }
290
+ } else if (parsed.kind === "malformed_tool_call") {
291
+ textMalformed = { error: parsed.error, raw: parsed.raw };
292
+ } else {
293
+ calls = [{
294
+ id: parsed.id,
295
+ name: parsed.name,
296
+ arguments: parsed.arguments,
297
+ rawArguments: parsed.rawArguments,
298
+ }];
299
+ }
300
+ }
301
+
302
+ if (toolProtocol === "none" && calls.length > 0) {
303
+ textMalformed = { error: "Tool calls are disabled by the selected model capability profile.", raw: response.text };
304
+ calls = [];
305
+ }
306
+
307
+ const callIds = calls.map((call, index) => call.id ?? `local-call-${toolCallCount + index + 1}`);
308
+ const malformedIds = malformedCalls.map((call, index) => call.id ?? `local-malformed-${toolCallCount + index + 1}`);
309
+ if (native) {
310
+ messages.push({
311
+ role: "assistant",
312
+ content: response.text || null,
313
+ ...(response.reasoning?.trim() ? { reasoning_content: response.reasoning } : {}),
314
+ tool_calls: [
315
+ ...calls.map((call, index) => wireToolCall(call, callIds[index]!)),
316
+ ...malformedCalls.map((call, index) => malformedWireToolCall(call, malformedIds[index]!)),
317
+ ],
318
+ });
319
+ } else {
320
+ messages.push({ role: "assistant", content: response.text });
321
+ }
322
+
323
+ if (textMalformed) {
324
+ toolCallCount += 1;
325
+ appendToolResultMessage(messages, false, "", {
326
+ success: false,
327
+ error: `Malformed tool call: ${textMalformed.error}`,
328
+ raw: textMalformed.raw,
329
+ });
330
+ consecutiveNoProgressCalls += 1;
331
+ if (consecutiveNoProgressCalls >= maxConsecutiveNoProgressCalls) {
332
+ const reason = "The Local agent repeated malformed tool calls without making progress.";
333
+ const final = await requestFinalAnswer(options, messages, toolCallCount, reason);
334
+ return final ?? synthesizeFinalMessage(options.request, summary, reason);
335
+ }
336
+ continue;
337
+ }
338
+
339
+ for (let index = 0; index < malformedCalls.length; index += 1) {
340
+ const malformed = malformedCalls[index]!;
341
+ toolCallCount += 1;
342
+ appendToolResultMessage(messages, true, malformedIds[index]!, {
343
+ success: false,
344
+ tool: malformed.name ?? "unknown",
345
+ error: `Malformed tool call: ${malformed.error}`,
346
+ raw: malformed.rawArguments,
347
+ });
348
+ consecutiveNoProgressCalls += 1;
349
+ }
350
+ if (consecutiveNoProgressCalls >= maxConsecutiveNoProgressCalls) {
351
+ const reason = "The Local agent repeated malformed tool calls without making progress.";
352
+ const final = await requestFinalAnswer(options, messages, toolCallCount, reason);
353
+ return final ?? synthesizeFinalMessage(options.request, summary, reason);
354
+ }
355
+
356
+ for (let index = 0; index < calls.length; index += 1) {
357
+ const call = calls[index]!;
358
+ const callId = callIds[index]!;
359
+ const signature = toolCallSignature(call);
360
+ if (completedToolCallIds.has(callId)) {
361
+ const reason = `Local agent replayed completed tool call ID ${callId}.`;
362
+ appendToolResultMessage(messages, native, callId, {
363
+ success: false,
364
+ tool: call.name,
365
+ error: reason,
366
+ });
367
+ consecutiveNoProgressCalls += 1;
368
+ if (consecutiveNoProgressCalls >= maxConsecutiveNoProgressCalls) {
369
+ const finalReason = "The Local agent replayed completed tool calls without making progress.";
370
+ const final = await requestFinalAnswer(options, messages, toolCallCount, finalReason);
371
+ return final ?? synthesizeFinalMessage(options.request, summary, finalReason);
372
+ }
373
+ continue;
374
+ }
375
+
376
+ completedToolCallIds.add(callId);
377
+ toolCallCount += 1;
378
+ const activityId = `local-agent-${toolCallCount}-${call.name}`;
379
+ const startedAt = Date.now();
380
+ const runningCommand = toolActivityCommand({
381
+ tool: call.name,
382
+ path: typeof call.arguments.path === "string" ? call.arguments.path : undefined,
383
+ command: typeof call.arguments.command === "string" ? call.arguments.command : undefined,
384
+ });
385
+ const mutating = call.name === "write_file" || call.name === "apply_patch" || call.name === "run_shell";
386
+ let deniedReason: string | null = null;
387
+ if (mutating && options.request.runIntent === "plan") {
388
+ deniedReason = "This tool is unavailable in Plan mode. Continue with read-only inspection and return a plan.";
389
+ } else if (
390
+ mutating
391
+ && options.request.runtime.policy.approvalPolicy !== "never"
392
+ && !approvedForRun.has(signature)
393
+ ) {
394
+ const rawPath = typeof call.arguments.path === "string" ? call.arguments.path : null;
395
+ const patchPaths = call.name === "apply_patch" && typeof call.arguments.patch === "string"
396
+ ? [...call.arguments.patch.matchAll(/^\*\*\* (?:Add|Update|Delete) File:\s*(.+)$/gm)].map((match) => match[1]!.trim())
397
+ : [];
398
+ const decision = await options.handlers.onToolApproval?.({
399
+ tool: call.name,
400
+ signature,
401
+ command: typeof call.arguments.command === "string" ? call.arguments.command : undefined,
402
+ paths: rawPath ? [rawPath] : patchPaths,
403
+ }) ?? "deny";
404
+ if (decision === "deny") deniedReason = "User denied this local-model action.";
405
+ if (decision === "allow-for-run") approvedForRun.add(signature);
406
+ }
407
+
408
+ if (deniedReason) {
409
+ const denied: AgentToolResult = { success: false, tool: call.name, error: deniedReason };
410
+ options.handlers.onToolActivity?.({
411
+ id: activityId,
412
+ command: runningCommand,
413
+ status: "failed",
414
+ startedAt,
415
+ completedAt: Date.now(),
416
+ summary: deniedReason,
417
+ });
418
+ recordToolResult(summary, denied);
419
+ appendToolResultMessage(messages, native, callId, denied);
420
+ const deniedFingerprint = toolResultFingerprint(denied);
421
+ const previousDenied = previousToolResults.get(signature);
422
+ previousToolResults.set(signature, deniedFingerprint);
423
+ consecutiveNoProgressCalls = previousDenied === deniedFingerprint
424
+ ? consecutiveNoProgressCalls + 1
425
+ : 0;
426
+ if (consecutiveNoProgressCalls >= maxConsecutiveNoProgressCalls) {
427
+ const reason = "The Local agent repeated denied tool calls without making progress.";
428
+ const final = await requestFinalAnswer(options, messages, toolCallCount, reason);
429
+ return final ?? synthesizeFinalMessage(options.request, summary, reason);
430
+ }
431
+ continue;
432
+ }
433
+
434
+ options.handlers.onToolActivity?.({ id: activityId, command: runningCommand, status: "running", startedAt });
435
+ const result = await executeAgentTool(call.name, call.arguments, {
436
+ workspaceRoot: options.request.workspaceRoot,
437
+ runtime: options.request.runtime,
438
+ signal: options.signal,
439
+ });
440
+ const completedCommand = toolActivityCommand(result);
441
+ options.handlers.onToolActivity?.({
442
+ id: activityId,
443
+ command: completedCommand,
444
+ status: result.success ? "completed" : "failed",
445
+ startedAt,
446
+ completedAt: Date.now(),
447
+ summary: result.summary ?? result.error ?? null,
448
+ });
449
+ recordToolResult(summary, result);
450
+ appendToolResultMessage(messages, native, callId, result);
451
+ const resultFingerprint = toolResultFingerprint(result);
452
+ const previousResult = previousToolResults.get(signature);
453
+ previousToolResults.set(signature, resultFingerprint);
454
+ consecutiveNoProgressCalls = previousResult === resultFingerprint
455
+ ? consecutiveNoProgressCalls + 1
456
+ : 0;
457
+ if (consecutiveNoProgressCalls >= maxConsecutiveNoProgressCalls) {
458
+ const reason = "The Local agent repeated tool calls with unchanged results and could not make further progress.";
459
+ const final = await requestFinalAnswer(options, messages, toolCallCount, reason);
460
+ return final ?? synthesizeFinalMessage(options.request, summary, reason);
461
+ }
462
+ }
463
+ }
464
+ }