@golba98/codexa 1.0.3 → 1.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -18
- package/bin/codexa.js +62 -144
- package/package.json +4 -3
- package/src/app.tsx +533 -275
- package/src/commands/handler.ts +2 -2
- package/src/config/buildInfo.ts +2 -2
- package/src/config/layeredConfig.ts +1 -1
- package/src/config/runtimeConfig.ts +1 -1
- package/src/config/settings.ts +1 -1
- package/src/config/trustStore.ts +1 -1
- package/src/core/README.md +52 -0
- package/src/core/agent/loop.ts +282 -0
- package/src/core/agent/protocol.ts +211 -0
- package/src/core/agent/tools.ts +414 -0
- package/src/core/{codexExecArgs.ts → codex/codexExecArgs.ts} +2 -2
- package/src/core/{codexLaunch.ts → codex/codexLaunch.ts} +3 -3
- package/src/core/{codexPrompt.ts → codex/codexPrompt.ts} +3 -3
- package/src/core/debug/modelStateDebug.ts +34 -0
- package/src/core/executables/antigravityExecutable.ts +48 -0
- package/src/core/executables/executableResolver.ts +5 -1
- package/src/core/perf/renderDebug.ts +10 -6
- package/src/core/providerLauncher/registry.ts +30 -14
- package/src/core/providerLauncher/types.ts +11 -9
- package/src/core/providerLauncher/workspaceConfig.ts +41 -26
- package/src/core/providerRuntime/anthropic.ts +7 -1
- package/src/core/providerRuntime/antigravity.ts +305 -0
- package/src/core/providerRuntime/claudeCodeDiscovery.ts +268 -22
- package/src/core/providerRuntime/claudeCodeDiscoveryDebug.ts +55 -0
- package/src/core/providerRuntime/contextMetadata.ts +12 -24
- package/src/core/providerRuntime/local.ts +129 -51
- package/src/core/providerRuntime/models.ts +22 -11
- package/src/core/providerRuntime/registry.ts +58 -31
- package/src/core/providerRuntime/types.ts +19 -14
- package/src/core/providers/codexSubprocess.ts +2 -2
- package/src/core/providers/types.ts +1 -1
- package/src/core/{attachments.ts → shared/attachments.ts} +27 -4
- package/src/core/{cleanupFastFail.ts → shared/cleanupFastFail.ts} +1 -1
- package/src/core/{hollowResponseFormat.ts → shared/hollowResponseFormat.ts} +1 -1
- package/src/core/terminal/clearFrameBoundary.ts +814 -0
- package/src/core/terminal/frameLock.ts +109 -0
- package/src/core/terminal/inkRenderReset.ts +123 -0
- package/src/core/terminal/terminalControl.ts +22 -0
- package/src/core/terminal/terminalTitle.ts +16 -102
- package/src/core/{channel.ts → version/channel.ts} +1 -1
- package/src/core/{updateCheck.ts → version/updateCheck.ts} +10 -5
- package/src/core/{launchContext.ts → workspace/launchContext.ts} +9 -16
- package/src/core/{planStorage.ts → workspace/planStorage.ts} +2 -2
- package/src/core/{workspaceGuard.ts → workspace/workspaceGuard.ts} +97 -13
- package/src/headless/execRunner.ts +2 -2
- package/src/index.tsx +43 -89
- package/src/session/appSession.ts +10 -7
- package/src/session/chatLifecycle.ts +1 -1
- package/src/session/liveRenderScheduler.ts +1 -1
- package/src/session/types.ts +1 -1
- package/src/ui/AppShell.tsx +672 -706
- package/src/ui/AttachmentImportPanel.tsx +2 -2
- package/src/ui/BottomComposer.tsx +145 -144
- package/src/ui/ModelPickerScreen.tsx +216 -36
- package/src/ui/ModelReasoningPicker.tsx +1 -1
- package/src/ui/PlanReviewPanel.tsx +1 -1
- package/src/ui/ProviderPicker.tsx +735 -321
- package/src/ui/RuntimeStatusBar.tsx +108 -0
- package/src/ui/SelectionPanel.tsx +4 -0
- package/src/ui/SettingsPanel.tsx +1 -1
- package/src/ui/TextEntryPanel.tsx +1 -1
- package/src/ui/Timeline.tsx +1619 -1470
- package/src/ui/TopHeader.tsx +46 -30
- package/src/ui/TranscriptShell.tsx +322 -0
- package/src/ui/TurnGroup.tsx +2 -2
- package/src/ui/UpdateAvailableCard.tsx +3 -3
- package/src/ui/UpdatePromptPanel.tsx +16 -22
- package/src/ui/layout.ts +298 -24
- package/src/ui/layoutListWindow.ts +145 -0
- package/src/ui/logoVariants.ts +4 -8
- package/src/ui/runtimeDisplay.ts +15 -3
- package/src/ui/textLayout.ts +15 -4
- package/src/ui/timelineMeasure.ts +194 -122
- package/src/core/codex.ts +0 -124
- package/src/ui/StaticTranscriptItem.tsx +0 -56
- /package/src/core/{inputDebug.ts → debug/inputDebug.ts} +0 -0
- /package/src/core/{clipboard.ts → shared/clipboard.ts} +0 -0
- /package/src/core/{githubDiagnostics.ts → shared/githubDiagnostics.ts} +0 -0
- /package/src/core/{projectInstructions.ts → workspace/projectInstructions.ts} +0 -0
- /package/src/core/{workspaceActivity.ts → workspace/workspaceActivity.ts} +0 -0
- /package/src/core/{workspaceRoot.ts → workspace/workspaceRoot.ts} +0 -0
package/src/commands/handler.ts
CHANGED
|
@@ -36,7 +36,7 @@ import {
|
|
|
36
36
|
type WorkspaceDisplayMode,
|
|
37
37
|
} from "../config/settings.js";
|
|
38
38
|
|
|
39
|
-
import type { WorkspaceCommandContext } from "../core/launchContext.js";
|
|
39
|
+
import type { WorkspaceCommandContext } from "../core/workspace/launchContext.js";
|
|
40
40
|
import {
|
|
41
41
|
findModelCapability,
|
|
42
42
|
formatModelCapabilitiesList,
|
|
@@ -120,7 +120,7 @@ export interface CommandContext {
|
|
|
120
120
|
modelCapabilities?: CodexModelCapabilities | null;
|
|
121
121
|
routeStatusMessage?: string;
|
|
122
122
|
activeRouteProviderLabel?: string;
|
|
123
|
-
projectInstructions?: import("../core/projectInstructions.js").ProjectInstructionsLoadResult | null;
|
|
123
|
+
projectInstructions?: import("../core/workspace/projectInstructions.js").ProjectInstructionsLoadResult | null;
|
|
124
124
|
}
|
|
125
125
|
|
|
126
126
|
// Mirrors AVAILABLE_APPROVAL_POLICIES[].id from runtimeConfig.ts
|
package/src/config/buildInfo.ts
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
// Generated by scripts/gen-build-info.mjs — do not edit manually.
|
|
2
|
-
export const BUILD_COMMIT = "
|
|
3
|
-
export const APP_VERSION = "1.0.
|
|
2
|
+
export const BUILD_COMMIT = "b066a15b77696d2a0945ea7c6c3d07630584296b" as const;
|
|
3
|
+
export const APP_VERSION = "1.0.4" as const;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "fs";
|
|
2
2
|
import { dirname, join, resolve } from "path";
|
|
3
|
-
import { normalizeWorkspaceRoot } from "../core/workspaceRoot.js";
|
|
3
|
+
import { normalizeWorkspaceRoot } from "../core/workspace/workspaceRoot.js";
|
|
4
4
|
import {
|
|
5
5
|
formatApprovalPolicyLabel,
|
|
6
6
|
formatNetworkAccessLabel,
|
|
@@ -106,7 +106,7 @@ export interface ResolvedRuntimeConfig {
|
|
|
106
106
|
export interface RuntimeStatusContext {
|
|
107
107
|
workspaceRoot: string;
|
|
108
108
|
tokensUsed?: number | null;
|
|
109
|
-
projectInstructions?: import("../core/projectInstructions.js").ProjectInstructionsLoadResult | null;
|
|
109
|
+
projectInstructions?: import("../core/workspace/projectInstructions.js").ProjectInstructionsLoadResult | null;
|
|
110
110
|
}
|
|
111
111
|
|
|
112
112
|
export interface RuntimeSummary {
|
package/src/config/settings.ts
CHANGED
|
@@ -101,7 +101,7 @@ export const TERMINAL_MOUSE_MODES = ["wheel", "selection"] as const;
|
|
|
101
101
|
|
|
102
102
|
export type TerminalMouseMode = (typeof TERMINAL_MOUSE_MODES)[number];
|
|
103
103
|
|
|
104
|
-
export const DEFAULT_TERMINAL_MOUSE_MODE: TerminalMouseMode = "
|
|
104
|
+
export const DEFAULT_TERMINAL_MOUSE_MODE: TerminalMouseMode = "wheel";
|
|
105
105
|
|
|
106
106
|
export interface SettingOption<TValue extends string> {
|
|
107
107
|
value: TValue;
|
package/src/config/trustStore.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "fs";
|
|
2
2
|
import { dirname } from "path";
|
|
3
3
|
import { getCodexaTrustStoreFile } from "./settings.js";
|
|
4
|
-
import { normalizeWorkspaceRoot } from "../core/workspaceRoot.js";
|
|
4
|
+
import { normalizeWorkspaceRoot } from "../core/workspace/workspaceRoot.js";
|
|
5
5
|
|
|
6
6
|
interface TrustStoreData {
|
|
7
7
|
trustedProjectRoots: string[];
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# `src/core`
|
|
2
|
+
|
|
3
|
+
Non-UI runtime logic for Codexa: 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 (`CODEXA_DEBUG_INPUT=1`).
|
|
49
|
+
- `perf/renderDebug.ts` — Ink render/flicker tracing (`CODEXA_RENDER_DEBUG=1`). Kept in
|
|
50
|
+
`perf/` rather than `debug/` because it is imported widely across the UI.
|
|
51
|
+
- `providerRuntime/claudeCodeDiscoveryDebug.ts` — entry point for the
|
|
52
|
+
`bun run debug:claude-models` script; lives next to the discovery code it exercises.
|
|
@@ -0,0 +1,282 @@
|
|
|
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 { parseAgentToolCall, serializeToolResult, type NormalizedAgentToolCall } from "./protocol.js";
|
|
7
|
+
|
|
8
|
+
export interface AgentChatMessage {
|
|
9
|
+
role: "system" | "user" | "assistant" | "tool";
|
|
10
|
+
content: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface AgentChatResponse {
|
|
14
|
+
text: string;
|
|
15
|
+
toolCalls?: readonly NormalizedAgentToolCall[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface RunAgentLoopOptions {
|
|
19
|
+
request: ProviderChatRequest;
|
|
20
|
+
handlers: BackendRunHandlers;
|
|
21
|
+
sendMessages: (messages: readonly AgentChatMessage[], turnIndex: number) => Promise<AgentChatResponse>;
|
|
22
|
+
includeSystemPrompt: boolean;
|
|
23
|
+
signal?: AbortSignal;
|
|
24
|
+
maxToolCalls?: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const DEFAULT_MAX_TOOL_CALLS = 10;
|
|
28
|
+
|
|
29
|
+
function workspaceSummary(workspaceRoot: string): string {
|
|
30
|
+
const lines = [`Workspace root: ${workspaceRoot}`];
|
|
31
|
+
try {
|
|
32
|
+
const entries = readdirSync(workspaceRoot, { withFileTypes: true })
|
|
33
|
+
.filter((entry) => ![".git", "node_modules", "dist", "build", "coverage"].includes(entry.name))
|
|
34
|
+
.map((entry) => `${entry.name}${entry.isDirectory() ? "/" : ""}`)
|
|
35
|
+
.sort()
|
|
36
|
+
.slice(0, 20);
|
|
37
|
+
if (entries.length > 0) lines.push(`Top-level entries: ${entries.join(", ")}`);
|
|
38
|
+
} catch {
|
|
39
|
+
// Tool-based inspection remains available when a shallow summary is unavailable.
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
const packageJsonPath = path.join(workspaceRoot, "package.json");
|
|
44
|
+
if (existsSync(packageJsonPath)) {
|
|
45
|
+
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as Record<string, unknown>;
|
|
46
|
+
const name = typeof packageJson.name === "string" ? packageJson.name : null;
|
|
47
|
+
const description = typeof packageJson.description === "string" ? packageJson.description : null;
|
|
48
|
+
if (name) lines.push(`Package: ${name}${description ? ` - ${description}` : ""}`);
|
|
49
|
+
}
|
|
50
|
+
} catch {
|
|
51
|
+
// A malformed package.json should not prevent a local chat request.
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return lines.join("\n");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function localAgentSystemPrompt(request: ProviderChatRequest): string {
|
|
58
|
+
const hasCargoToml = existsSync(path.join(request.workspaceRoot, "Cargo.toml"));
|
|
59
|
+
return [
|
|
60
|
+
`You are an autonomous coding assistant running inside this workspace: ${request.workspaceRoot}`,
|
|
61
|
+
"You must inspect files with tools before claiming you cannot see them.",
|
|
62
|
+
"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.",
|
|
63
|
+
"Use tools to create, edit, build, and test when the user asks for workspace changes.",
|
|
64
|
+
"Do not ask vague clarification questions when the user's intent has an obvious safe implementation.",
|
|
65
|
+
hasCargoToml
|
|
66
|
+
? "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."
|
|
67
|
+
: null,
|
|
68
|
+
"Use exactly one tool call at a time in this format:",
|
|
69
|
+
'<tool_call>{"name":"read_file","arguments":{"path":"src/index.tsx"}}</tool_call>',
|
|
70
|
+
"Available tools: list_files, read_file, write_file, apply_patch, run_shell, get_workspace_info.",
|
|
71
|
+
"Summarize changed files and commands run in your final answer.",
|
|
72
|
+
`Workspace summary:\n${workspaceSummary(request.workspaceRoot)}`,
|
|
73
|
+
request.projectInstructions?.content
|
|
74
|
+
? ["Project instructions:", request.projectInstructions.content].join("\n")
|
|
75
|
+
: null,
|
|
76
|
+
].filter(Boolean).join("\n\n");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function buildInitialMessages(request: ProviderChatRequest, includeSystemPrompt: boolean): AgentChatMessage[] {
|
|
80
|
+
const systemPrompt = localAgentSystemPrompt(request);
|
|
81
|
+
if (includeSystemPrompt) {
|
|
82
|
+
return [
|
|
83
|
+
{ role: "system", content: systemPrompt },
|
|
84
|
+
{ role: "user", content: request.prompt },
|
|
85
|
+
];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return [
|
|
89
|
+
{ role: "user", content: `${systemPrompt}\n\nUser request:\n${request.prompt}` },
|
|
90
|
+
];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function toolActivityCommand(result: Pick<AgentToolResult, "tool" | "path" | "paths" | "command">): string {
|
|
94
|
+
if (result.command) return `${result.tool}: ${result.command}`;
|
|
95
|
+
if (result.path) return `${result.tool}: ${result.path}`;
|
|
96
|
+
if (result.paths && result.paths.length > 0) return `${result.tool}: ${result.paths.join(", ")}`;
|
|
97
|
+
return result.tool;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
interface ExecutedCommand {
|
|
101
|
+
command: string;
|
|
102
|
+
success: boolean;
|
|
103
|
+
exitCode?: number | null;
|
|
104
|
+
durationMs?: number;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
interface AgentLoopSummary {
|
|
108
|
+
changedFiles: Set<string>;
|
|
109
|
+
commands: ExecutedCommand[];
|
|
110
|
+
toolResults: AgentToolResult[];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function stableJson(value: unknown): string {
|
|
114
|
+
if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
|
|
115
|
+
if (value && typeof value === "object") {
|
|
116
|
+
const record = value as Record<string, unknown>;
|
|
117
|
+
return `{${Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${stableJson(record[key])}`).join(",")}}`;
|
|
118
|
+
}
|
|
119
|
+
return JSON.stringify(value);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function toolCallSignature(call: Pick<NormalizedAgentToolCall, "name" | "arguments">): string {
|
|
123
|
+
return `${call.name}:${stableJson(call.arguments)}`;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function recordToolResult(summary: AgentLoopSummary, result: AgentToolResult): void {
|
|
127
|
+
for (const file of result.paths ?? []) {
|
|
128
|
+
if (file) summary.changedFiles.add(file);
|
|
129
|
+
}
|
|
130
|
+
if (result.path && (result.tool === "write_file" || result.tool === "apply_patch")) {
|
|
131
|
+
summary.changedFiles.add(result.path);
|
|
132
|
+
}
|
|
133
|
+
if (result.command) {
|
|
134
|
+
summary.commands.push({
|
|
135
|
+
command: result.command,
|
|
136
|
+
success: result.success,
|
|
137
|
+
exitCode: result.exitCode,
|
|
138
|
+
durationMs: result.durationMs,
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
summary.toolResults.push(result);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function commandStatus(command: ExecutedCommand): string {
|
|
145
|
+
const status = command.success ? "succeeded" : "failed";
|
|
146
|
+
const exitCode = command.exitCode === undefined ? "" : `, exit ${command.exitCode ?? "n/a"}`;
|
|
147
|
+
return `- ${command.command}: ${status}${exitCode}`;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function nextCommand(request: ProviderChatRequest, summary: AgentLoopSummary): string {
|
|
151
|
+
if (existsSync(path.join(request.workspaceRoot, "Cargo.toml"))) return "cargo run";
|
|
152
|
+
const lastValidation = [...summary.commands].reverse().find((item) =>
|
|
153
|
+
/\b(?:cargo check|cargo run|bun test|npm test|bun run typecheck|tsc)\b/.test(item.command)
|
|
154
|
+
);
|
|
155
|
+
if (lastValidation) return lastValidation.command;
|
|
156
|
+
return "bun test";
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function synthesizeFinalMessage(request: ProviderChatRequest, summary: AgentLoopSummary, reason: string): string {
|
|
160
|
+
const files = [...summary.changedFiles].sort();
|
|
161
|
+
const commandLines = summary.commands.map(commandStatus);
|
|
162
|
+
return [
|
|
163
|
+
reason,
|
|
164
|
+
"",
|
|
165
|
+
"Files changed:",
|
|
166
|
+
files.length > 0 ? files.map((file) => `- ${file}`).join("\n") : "- None detected",
|
|
167
|
+
"",
|
|
168
|
+
"Commands run:",
|
|
169
|
+
commandLines.length > 0 ? commandLines.join("\n") : "- None",
|
|
170
|
+
"",
|
|
171
|
+
`Next command: ${nextCommand(request, summary)}`,
|
|
172
|
+
].join("\n").trim();
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function requestFinalAnswer(options: RunAgentLoopOptions, messages: AgentChatMessage[], toolCallCount: number, reason: string): Promise<string | null> {
|
|
176
|
+
messages.push({
|
|
177
|
+
role: "user",
|
|
178
|
+
content: [
|
|
179
|
+
reason,
|
|
180
|
+
"Stop calling tools now. Write the final answer using the tool results already provided.",
|
|
181
|
+
"Include files changed, commands run, whether they succeeded, and the next command the user can run.",
|
|
182
|
+
].join("\n"),
|
|
183
|
+
});
|
|
184
|
+
const response = await options.sendMessages(messages, toolCallCount);
|
|
185
|
+
const parsed = parseAgentToolCall(response.text);
|
|
186
|
+
return parsed.kind === "final" && parsed.text.trim() ? parsed.text.trim() : null;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export async function runAgentLoop(options: RunAgentLoopOptions): Promise<string> {
|
|
190
|
+
const maxToolCalls = options.maxToolCalls ?? DEFAULT_MAX_TOOL_CALLS;
|
|
191
|
+
const messages = buildInitialMessages(options.request, options.includeSystemPrompt);
|
|
192
|
+
let toolCallCount = 0;
|
|
193
|
+
let previousToolSignature: string | null = null;
|
|
194
|
+
const summary: AgentLoopSummary = {
|
|
195
|
+
changedFiles: new Set(),
|
|
196
|
+
commands: [],
|
|
197
|
+
toolResults: [],
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
while (true) {
|
|
201
|
+
if (options.signal?.aborted) {
|
|
202
|
+
throw new Error("Local agent run was canceled.");
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const response = await options.sendMessages(messages, toolCallCount);
|
|
206
|
+
const structuredToolCall = response.toolCalls?.[0] ?? null;
|
|
207
|
+
const parsed = structuredToolCall
|
|
208
|
+
? { kind: "tool_call" as const, ...structuredToolCall, raw: JSON.stringify(structuredToolCall) }
|
|
209
|
+
: parseAgentToolCall(response.text);
|
|
210
|
+
|
|
211
|
+
if (parsed.kind === "final") {
|
|
212
|
+
return parsed.text.trim();
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (toolCallCount >= maxToolCalls) {
|
|
216
|
+
const reason = `Local agent reached ${maxToolCalls} tool calls without a final answer.`;
|
|
217
|
+
const final = await requestFinalAnswer(options, messages, toolCallCount, reason);
|
|
218
|
+
return final ?? synthesizeFinalMessage(options.request, summary, reason);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (parsed.kind === "malformed_tool_call") {
|
|
222
|
+
messages.push({ role: "assistant", content: response.text });
|
|
223
|
+
toolCallCount += 1;
|
|
224
|
+
messages.push({
|
|
225
|
+
role: "user",
|
|
226
|
+
content: serializeToolResult({
|
|
227
|
+
success: false,
|
|
228
|
+
error: `Malformed tool call: ${parsed.error}`,
|
|
229
|
+
raw: parsed.raw,
|
|
230
|
+
}),
|
|
231
|
+
});
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const signature = toolCallSignature(parsed);
|
|
236
|
+
if (signature === previousToolSignature) {
|
|
237
|
+
const reason = `Local agent repeated the same ${parsed.name} tool call with identical arguments.`;
|
|
238
|
+
const final = await requestFinalAnswer(options, messages, toolCallCount, reason);
|
|
239
|
+
return final ?? synthesizeFinalMessage(options.request, summary, reason);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
messages.push({ role: "assistant", content: response.text || parsed.raw });
|
|
243
|
+
previousToolSignature = signature;
|
|
244
|
+
toolCallCount += 1;
|
|
245
|
+
const activityId = `local-agent-${toolCallCount}-${parsed.name}`;
|
|
246
|
+
const startedAt = Date.now();
|
|
247
|
+
const runningCommand = toolActivityCommand({
|
|
248
|
+
tool: parsed.name,
|
|
249
|
+
path: typeof parsed.arguments.path === "string" ? parsed.arguments.path : undefined,
|
|
250
|
+
command: typeof parsed.arguments.command === "string" ? parsed.arguments.command : undefined,
|
|
251
|
+
});
|
|
252
|
+
options.handlers.onToolActivity?.({
|
|
253
|
+
id: activityId,
|
|
254
|
+
command: runningCommand,
|
|
255
|
+
status: "running",
|
|
256
|
+
startedAt,
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
const result = await executeAgentTool(parsed.name, parsed.arguments, {
|
|
260
|
+
workspaceRoot: options.request.workspaceRoot,
|
|
261
|
+
runtime: options.request.runtime,
|
|
262
|
+
signal: options.signal,
|
|
263
|
+
});
|
|
264
|
+
const completedCommand = toolActivityCommand(result);
|
|
265
|
+
options.handlers.onToolActivity?.({
|
|
266
|
+
id: activityId,
|
|
267
|
+
command: completedCommand,
|
|
268
|
+
status: result.success ? "completed" : "failed",
|
|
269
|
+
startedAt,
|
|
270
|
+
completedAt: Date.now(),
|
|
271
|
+
summary: result.summary ?? result.error ?? null,
|
|
272
|
+
});
|
|
273
|
+
recordToolResult(summary, result);
|
|
274
|
+
|
|
275
|
+
messages.push({
|
|
276
|
+
role: "user",
|
|
277
|
+
content: serializeToolResult(result),
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
throw new Error(`Local agent stopped after ${maxToolCalls} tool calls without a final answer.`);
|
|
282
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
export type AgentToolName =
|
|
2
|
+
| "list_files"
|
|
3
|
+
| "read_file"
|
|
4
|
+
| "write_file"
|
|
5
|
+
| "apply_patch"
|
|
6
|
+
| "run_shell"
|
|
7
|
+
| "get_workspace_info";
|
|
8
|
+
|
|
9
|
+
export interface ParsedAgentToolCall {
|
|
10
|
+
kind: "tool_call";
|
|
11
|
+
name: AgentToolName;
|
|
12
|
+
arguments: Record<string, unknown>;
|
|
13
|
+
raw: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface NormalizedAgentToolCall {
|
|
17
|
+
name: AgentToolName;
|
|
18
|
+
arguments: Record<string, unknown>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface MalformedAgentToolCall {
|
|
22
|
+
kind: "malformed_tool_call";
|
|
23
|
+
raw: string;
|
|
24
|
+
error: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type AgentToolParseResult =
|
|
28
|
+
| { kind: "final"; text: string }
|
|
29
|
+
| ParsedAgentToolCall
|
|
30
|
+
| MalformedAgentToolCall;
|
|
31
|
+
|
|
32
|
+
const TOOL_CALL_PATTERN = /<tool_call>([\s\S]*?)<\/tool_call>/i;
|
|
33
|
+
const TOOL_CALL_OPEN_PATTERN = /<tool_call\b[^>]*>/i;
|
|
34
|
+
|
|
35
|
+
const TOOL_NAMES = new Set<AgentToolName>([
|
|
36
|
+
"list_files",
|
|
37
|
+
"read_file",
|
|
38
|
+
"write_file",
|
|
39
|
+
"apply_patch",
|
|
40
|
+
"run_shell",
|
|
41
|
+
"get_workspace_info",
|
|
42
|
+
]);
|
|
43
|
+
|
|
44
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
45
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function parseJsonMaybeWithExtraBrace(raw: string): unknown {
|
|
49
|
+
try {
|
|
50
|
+
return JSON.parse(raw) as unknown;
|
|
51
|
+
} catch (firstError) {
|
|
52
|
+
const trimmed = raw.trim();
|
|
53
|
+
if (trimmed.endsWith("}")) {
|
|
54
|
+
try {
|
|
55
|
+
return JSON.parse(trimmed.slice(0, -1)) as unknown;
|
|
56
|
+
} catch {
|
|
57
|
+
// Return the original parse error below.
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
throw firstError;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function parseArguments(value: unknown): Record<string, unknown> | null {
|
|
65
|
+
if (value === undefined || value === null) return {};
|
|
66
|
+
if (isRecord(value)) return value;
|
|
67
|
+
if (typeof value === "string") {
|
|
68
|
+
try {
|
|
69
|
+
const parsed = JSON.parse(value) as unknown;
|
|
70
|
+
return isRecord(parsed) ? parsed : null;
|
|
71
|
+
} catch {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function normalizeAgentToolCall(value: unknown): NormalizedAgentToolCall | null {
|
|
79
|
+
if (!isRecord(value)) return null;
|
|
80
|
+
|
|
81
|
+
const functionCall = isRecord(value.function) ? value.function : null;
|
|
82
|
+
const rawName = functionCall?.name ?? value.name ?? value.tool;
|
|
83
|
+
if (typeof rawName !== "string" || !TOOL_NAMES.has(rawName as AgentToolName)) {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const args = parseArguments(functionCall?.arguments ?? value.arguments ?? value.args);
|
|
88
|
+
if (!args) return null;
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
name: rawName as AgentToolName,
|
|
92
|
+
arguments: args,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function parseOpenAiToolCalls(value: unknown): NormalizedAgentToolCall[] {
|
|
97
|
+
if (!Array.isArray(value)) return [];
|
|
98
|
+
return value
|
|
99
|
+
.map((item) => normalizeAgentToolCall(item))
|
|
100
|
+
.filter((item): item is NormalizedAgentToolCall => Boolean(item));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function extractJsonObjectAfterToolCall(text: string): string | null {
|
|
104
|
+
const open = TOOL_CALL_OPEN_PATTERN.exec(text);
|
|
105
|
+
if (!open) return null;
|
|
106
|
+
|
|
107
|
+
const startSearch = open.index + open[0].length;
|
|
108
|
+
const firstBrace = text.indexOf("{", startSearch);
|
|
109
|
+
if (firstBrace < 0) return text.slice(startSearch).trim();
|
|
110
|
+
|
|
111
|
+
let depth = 0;
|
|
112
|
+
let inString = false;
|
|
113
|
+
let escaped = false;
|
|
114
|
+
for (let index = firstBrace; index < text.length; index += 1) {
|
|
115
|
+
const char = text[index];
|
|
116
|
+
if (escaped) {
|
|
117
|
+
escaped = false;
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if (char === "\\") {
|
|
121
|
+
escaped = inString;
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
if (char === "\"") {
|
|
125
|
+
inString = !inString;
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
if (inString) continue;
|
|
129
|
+
if (char === "{") depth += 1;
|
|
130
|
+
if (char === "}") {
|
|
131
|
+
depth -= 1;
|
|
132
|
+
if (depth === 0) {
|
|
133
|
+
return text.slice(firstBrace, index + 1).trim();
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return text.slice(firstBrace).replace(/<\/tool_call>.*/is, "").trim();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function parseToolCallPayload(raw: string): AgentToolParseResult {
|
|
142
|
+
try {
|
|
143
|
+
const parsed = parseJsonMaybeWithExtraBrace(raw);
|
|
144
|
+
const fromToolCalls = isRecord(parsed) ? parseOpenAiToolCalls(parsed.tool_calls) : [];
|
|
145
|
+
const normalized = fromToolCalls[0] ?? normalizeAgentToolCall(parsed);
|
|
146
|
+
if (!normalized) {
|
|
147
|
+
return { kind: "malformed_tool_call", raw, error: "Tool call JSON did not contain a supported tool call." };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
kind: "tool_call",
|
|
152
|
+
...normalized,
|
|
153
|
+
raw,
|
|
154
|
+
};
|
|
155
|
+
} catch (error) {
|
|
156
|
+
return {
|
|
157
|
+
kind: "malformed_tool_call",
|
|
158
|
+
raw,
|
|
159
|
+
error: error instanceof Error ? error.message : String(error),
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function parseAgentToolCall(text: string): AgentToolParseResult {
|
|
165
|
+
const closedMatch = TOOL_CALL_PATTERN.exec(text);
|
|
166
|
+
const raw = closedMatch?.[1]?.trim() ?? extractJsonObjectAfterToolCall(text);
|
|
167
|
+
if (!raw) {
|
|
168
|
+
return TOOL_CALL_OPEN_PATTERN.test(text)
|
|
169
|
+
? { kind: "malformed_tool_call", raw: "", error: "Tool call block did not contain JSON." }
|
|
170
|
+
: { kind: "final", text };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return parseToolCallPayload(raw);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function scalar(value: unknown): string {
|
|
177
|
+
if (value === undefined || value === null) return "";
|
|
178
|
+
if (typeof value === "string") return value;
|
|
179
|
+
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
|
180
|
+
return JSON.stringify(value);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function blockField(name: string, value: unknown): string[] {
|
|
184
|
+
return [`${name}:`, scalar(value)];
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function serializeToolResult(result: unknown): string {
|
|
188
|
+
const record = isRecord(result) ? result : {};
|
|
189
|
+
const tool = typeof record.tool === "string" ? record.tool : "unknown";
|
|
190
|
+
const lines: string[] = [`<tool_result name="${tool}">`];
|
|
191
|
+
|
|
192
|
+
lines.push(`success: ${scalar(record.success)}`);
|
|
193
|
+
if (record.command !== undefined) lines.push(`command: ${scalar(record.command)}`);
|
|
194
|
+
if (record.path !== undefined) lines.push(`path: ${scalar(record.path)}`);
|
|
195
|
+
if (record.paths !== undefined) lines.push(`paths: ${Array.isArray(record.paths) ? record.paths.join(", ") : scalar(record.paths)}`);
|
|
196
|
+
if (record.exitCode !== undefined) lines.push(`exit_code: ${scalar(record.exitCode)}`);
|
|
197
|
+
if (record.durationMs !== undefined) lines.push(`duration_ms: ${scalar(record.durationMs)}`);
|
|
198
|
+
if (record.summary !== undefined) lines.push(...blockField("summary", record.summary));
|
|
199
|
+
if (record.error !== undefined) lines.push(...blockField("error", record.error));
|
|
200
|
+
|
|
201
|
+
if (tool === "run_shell") {
|
|
202
|
+
lines.push(...blockField("stdout", record.stdout ?? record.output ?? ""));
|
|
203
|
+
lines.push(...blockField("stderr", record.stderr ?? ""));
|
|
204
|
+
} else if (record.output !== undefined) {
|
|
205
|
+
lines.push(...blockField("output", record.output));
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (record.raw !== undefined) lines.push(...blockField("raw", record.raw));
|
|
209
|
+
lines.push("</tool_result>");
|
|
210
|
+
return lines.join("\n");
|
|
211
|
+
}
|