@zvada/agent-server 0.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.
- package/LICENSE +21 -0
- package/README.md +71 -0
- package/package.json +87 -0
- package/src/client/client.ts +589 -0
- package/src/client/index.ts +18 -0
- package/src/client/transports.ts +84 -0
- package/src/core/agents/acp/acp-agent.ts +322 -0
- package/src/core/agents/acp/adapter.ts +260 -0
- package/src/core/agents/acp/client.ts +212 -0
- package/src/core/agents/acp/known-agents.ts +29 -0
- package/src/core/agents/acp/mappings.ts +136 -0
- package/src/core/agents/base.ts +145 -0
- package/src/core/agents/claude-code/adapter.ts +451 -0
- package/src/core/agents/claude-code/claude-agent.ts +235 -0
- package/src/core/agents/claude-code/generator-session.ts +344 -0
- package/src/core/agents/claude-code/options.ts +161 -0
- package/src/core/agents/claude-code/session-manager.ts +159 -0
- package/src/core/agents/codex-app-server/adapter.ts +214 -0
- package/src/core/agents/codex-app-server/client.ts +221 -0
- package/src/core/agents/codex-app-server/codex-app-server-agent.ts +385 -0
- package/src/core/agents/codex-items.ts +122 -0
- package/src/core/agents/codex-sdk/adapter.ts +204 -0
- package/src/core/agents/codex-sdk/codex-sdk-agent.ts +236 -0
- package/src/core/agents/config-fingerprint.ts +19 -0
- package/src/core/agents/error-classifier.ts +68 -0
- package/src/core/agents/registry.ts +40 -0
- package/src/core/agents/session-store.ts +72 -0
- package/src/core/agents/tool-meta.ts +68 -0
- package/src/core/agents/types.ts +54 -0
- package/src/core/index.ts +114 -0
- package/src/core/presets.ts +78 -0
- package/src/core/provision/extract.ts +31 -0
- package/src/core/provision/index.ts +10 -0
- package/src/core/provision/npm.ts +114 -0
- package/src/core/provision/pins.ts +51 -0
- package/src/core/provision/platform.ts +73 -0
- package/src/core/provision/provisioner.ts +478 -0
- package/src/core/proxy/anthropic-proxy.ts +69 -0
- package/src/core/proxy/api-key-store.ts +34 -0
- package/src/core/proxy/index.ts +7 -0
- package/src/core/runtime/agent-runtime.ts +363 -0
- package/src/core/runtime/event-processor.ts +218 -0
- package/src/core/runtime/event-sink.ts +37 -0
- package/src/core/utils/errors.ts +41 -0
- package/src/index.ts +4 -0
- package/src/protocol/async-queue.ts +68 -0
- package/src/protocol/config.ts +100 -0
- package/src/protocol/factories.ts +125 -0
- package/src/protocol/harness.ts +50 -0
- package/src/protocol/ids.ts +53 -0
- package/src/protocol/index.ts +16 -0
- package/src/protocol/lifecycle.ts +309 -0
- package/src/protocol/models.ts +45 -0
- package/src/protocol/part-input.ts +58 -0
- package/src/protocol/parts.ts +60 -0
- package/src/protocol/thinking.ts +32 -0
- package/src/protocol/tokens.ts +39 -0
- package/src/protocol/tool-state.ts +89 -0
- package/src/protocol/wire.ts +313 -0
- package/src/server/acp/binding.ts +163 -0
- package/src/server/acp/translate.ts +160 -0
- package/src/server/agent-server.ts +357 -0
- package/src/server/bin.ts +174 -0
- package/src/server/index.ts +24 -0
- package/src/server/install.ts +51 -0
- package/src/server/session-log.ts +66 -0
- package/src/server/transports.ts +149 -0
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DEFAULT_TOKEN_USAGE,
|
|
3
|
+
type Part,
|
|
4
|
+
type ReasoningPart,
|
|
5
|
+
type StopReason,
|
|
6
|
+
type StreamContext,
|
|
7
|
+
type TextPart,
|
|
8
|
+
type TokenUsage,
|
|
9
|
+
type ToolPart,
|
|
10
|
+
completeToolPart,
|
|
11
|
+
createReasoningPart,
|
|
12
|
+
createTextPart,
|
|
13
|
+
createToolPart,
|
|
14
|
+
} from "../../../protocol/index.ts";
|
|
15
|
+
import {
|
|
16
|
+
type CodexToolDescriptor,
|
|
17
|
+
type CodexToolItem,
|
|
18
|
+
codexToolDescriptor,
|
|
19
|
+
} from "../codex-items.ts";
|
|
20
|
+
import type { AdapterEvent, EventTransformer, TransformResult } from "../types.ts";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Parses `@openai/codex-sdk` ThreadEvents into AdapterEvents.
|
|
24
|
+
*
|
|
25
|
+
* Codex streams at item granularity (item.started/updated/completed), not token
|
|
26
|
+
* by token. For agent_message/reasoning items we still synthesize text deltas by
|
|
27
|
+
* diffing successive snapshots, so downstream consumers get incremental text.
|
|
28
|
+
* command_execution / file_change / mcp_tool_call / web_search items become tool
|
|
29
|
+
* parts that complete when the item reaches a terminal status.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
type Phase = "started" | "updated" | "completed";
|
|
33
|
+
|
|
34
|
+
/** One SDK thread item: the shared codex tool vocabulary plus streaming fields. */
|
|
35
|
+
interface ThreadItem extends CodexToolItem {
|
|
36
|
+
type: string;
|
|
37
|
+
text?: string;
|
|
38
|
+
message?: string;
|
|
39
|
+
}
|
|
40
|
+
interface CodexEvent {
|
|
41
|
+
type: string;
|
|
42
|
+
thread_id?: string;
|
|
43
|
+
item?: ThreadItem;
|
|
44
|
+
usage?: {
|
|
45
|
+
input_tokens?: number;
|
|
46
|
+
cached_input_tokens?: number;
|
|
47
|
+
output_tokens?: number;
|
|
48
|
+
reasoning_output_tokens?: number;
|
|
49
|
+
};
|
|
50
|
+
error?: { message?: string };
|
|
51
|
+
message?: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export class CodexSdkTransformer implements EventTransformer<unknown> {
|
|
55
|
+
private readonly ctx: StreamContext;
|
|
56
|
+
private readonly partByItem = new Map<string, Part>();
|
|
57
|
+
private readonly textLenByItem = new Map<string, number>();
|
|
58
|
+
|
|
59
|
+
private usage: TokenUsage = { ...DEFAULT_TOKEN_USAGE };
|
|
60
|
+
private stopReason?: StopReason;
|
|
61
|
+
private finishReason?: string;
|
|
62
|
+
private error?: string;
|
|
63
|
+
|
|
64
|
+
constructor(ctx: { sessionId: string }) {
|
|
65
|
+
this.ctx = { sessionId: ctx.sessionId, messageId: "" };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
process(raw: unknown): AdapterEvent[] {
|
|
69
|
+
const ev = raw as CodexEvent;
|
|
70
|
+
switch (ev.type) {
|
|
71
|
+
case "item.started":
|
|
72
|
+
return ev.item ? this.handleItem(ev.item, "started") : [];
|
|
73
|
+
case "item.updated":
|
|
74
|
+
return ev.item ? this.handleItem(ev.item, "updated") : [];
|
|
75
|
+
case "item.completed":
|
|
76
|
+
return ev.item ? this.handleItem(ev.item, "completed") : [];
|
|
77
|
+
case "turn.completed": {
|
|
78
|
+
// Completion supersedes earlier stream notices: codex emits non-fatal
|
|
79
|
+
// diagnostics (retry / skills-context-budget notices) as `error`
|
|
80
|
+
// events; the fatal signal is `turn.failed`, which arrives *instead*
|
|
81
|
+
// of completion.
|
|
82
|
+
this.error = undefined;
|
|
83
|
+
this.finishReason = "completed";
|
|
84
|
+
this.stopReason = "end_turn";
|
|
85
|
+
if (!ev.usage) return [];
|
|
86
|
+
this.usage = {
|
|
87
|
+
input: ev.usage.input_tokens ?? 0,
|
|
88
|
+
output: ev.usage.output_tokens ?? 0,
|
|
89
|
+
reasoning: ev.usage.reasoning_output_tokens ?? 0,
|
|
90
|
+
cache: { read: ev.usage.cached_input_tokens ?? 0, write: 0 },
|
|
91
|
+
};
|
|
92
|
+
// The SDK reports usage only at turn end; input covers the full prompt
|
|
93
|
+
// context, so it doubles as the context gauge.
|
|
94
|
+
const used =
|
|
95
|
+
(ev.usage.input_tokens ?? 0) +
|
|
96
|
+
(ev.usage.cached_input_tokens ?? 0) +
|
|
97
|
+
(ev.usage.output_tokens ?? 0);
|
|
98
|
+
return [{ kind: "usage", used }];
|
|
99
|
+
}
|
|
100
|
+
case "turn.failed":
|
|
101
|
+
this.error = ev.error?.message ?? "codex turn failed";
|
|
102
|
+
return [];
|
|
103
|
+
case "error":
|
|
104
|
+
this.error = ev.message ?? "codex stream error";
|
|
105
|
+
return [];
|
|
106
|
+
default:
|
|
107
|
+
return [];
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
finish(): TransformResult {
|
|
112
|
+
return {
|
|
113
|
+
usage: this.usage,
|
|
114
|
+
stopReason: this.stopReason,
|
|
115
|
+
finishReason: this.finishReason,
|
|
116
|
+
error: this.error,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
private handleItem(item: ThreadItem, phase: Phase): AdapterEvent[] {
|
|
121
|
+
switch (item.type) {
|
|
122
|
+
case "agent_message":
|
|
123
|
+
return this.handleText(item, phase, "text");
|
|
124
|
+
case "reasoning":
|
|
125
|
+
return this.handleText(item, phase, "reasoning");
|
|
126
|
+
case "error":
|
|
127
|
+
this.error = item.message ?? "codex item error";
|
|
128
|
+
return [];
|
|
129
|
+
default: {
|
|
130
|
+
// command_execution / file_change / mcp_tool_call / web_search share
|
|
131
|
+
// one descriptor vocabulary; unknown item types produce nothing.
|
|
132
|
+
const d = codexToolDescriptor(item.type);
|
|
133
|
+
return d ? this.handleTool(item, phase, d) : [];
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
private handleText(item: ThreadItem, phase: Phase, kind: "text" | "reasoning"): AdapterEvent[] {
|
|
139
|
+
const events: AdapterEvent[] = [];
|
|
140
|
+
let part = this.partByItem.get(item.id) as TextPart | ReasoningPart | undefined;
|
|
141
|
+
const isNew = !part;
|
|
142
|
+
if (!part) {
|
|
143
|
+
part =
|
|
144
|
+
kind === "text"
|
|
145
|
+
? createTextPart(this.ctx, "", true)
|
|
146
|
+
: createReasoningPart(this.ctx, "", true);
|
|
147
|
+
this.partByItem.set(item.id, part);
|
|
148
|
+
this.textLenByItem.set(item.id, 0);
|
|
149
|
+
}
|
|
150
|
+
// Open the part only while it will keep streaming. When the item is already
|
|
151
|
+
// complete on first sight (Codex often sends only `item.completed`), emit a
|
|
152
|
+
// single finalized snapshot below: pushing both part-open and part-update
|
|
153
|
+
// for the same in-place-mutated object doubles the text, since the processor
|
|
154
|
+
// clones each at emit time and by then both read as "done".
|
|
155
|
+
if (isNew && phase !== "completed") events.push({ kind: "part-open", part });
|
|
156
|
+
|
|
157
|
+
const text = item.text ?? "";
|
|
158
|
+
const prevLen = this.textLenByItem.get(item.id) ?? 0;
|
|
159
|
+
if (text.length > prevLen) {
|
|
160
|
+
const suffix = text.slice(prevLen);
|
|
161
|
+
part.text = text;
|
|
162
|
+
this.textLenByItem.set(item.id, text.length);
|
|
163
|
+
// Skip the delta for an item that's already complete on first sight: it
|
|
164
|
+
// never opened a part, so the processor would drop the orphan delta
|
|
165
|
+
// anyway. The finalized part-update below carries the full text.
|
|
166
|
+
if (!(isNew && phase === "completed")) {
|
|
167
|
+
events.push(
|
|
168
|
+
kind === "text"
|
|
169
|
+
? { kind: "text-delta", partId: part.id, text: suffix }
|
|
170
|
+
: { kind: "reasoning-delta", partId: part.id, text: suffix },
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (phase === "completed") {
|
|
176
|
+
part.text = text || part.text;
|
|
177
|
+
part.state = "done";
|
|
178
|
+
events.push({ kind: "part-update", part });
|
|
179
|
+
}
|
|
180
|
+
return events;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
private handleTool(item: ThreadItem, phase: Phase, d: CodexToolDescriptor): AdapterEvent[] {
|
|
184
|
+
const events: AdapterEvent[] = [];
|
|
185
|
+
let part = this.partByItem.get(item.id) as ToolPart | undefined;
|
|
186
|
+
const isNew = !part;
|
|
187
|
+
if (!part) {
|
|
188
|
+
part = createToolPart(this.ctx, item.id, d.toolName(item), d.input(item), d.meta(item));
|
|
189
|
+
this.partByItem.set(item.id, part);
|
|
190
|
+
}
|
|
191
|
+
// As in handleText: skip the open when the tool is already complete on
|
|
192
|
+
// arrival, so we emit one finalized snapshot rather than two aliased ones.
|
|
193
|
+
if (isNew && phase !== "completed") events.push({ kind: "part-open", part });
|
|
194
|
+
if (phase === "completed") {
|
|
195
|
+
completeToolPart(part, d.result(item));
|
|
196
|
+
events.push({ kind: "part-update", part });
|
|
197
|
+
}
|
|
198
|
+
return events;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function createCodexSdkTransformer(ctx: { sessionId: string }): EventTransformer {
|
|
203
|
+
return new CodexSdkTransformer(ctx);
|
|
204
|
+
}
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import type { Codex, Thread, ThreadEvent, ThreadOptions } from "@openai/codex-sdk";
|
|
2
|
+
import type { AgentCapabilities, AgentInput, PermissionMode } from "../../../protocol/index.ts";
|
|
3
|
+
import { codexReasoningEffort } from "../../../protocol/index.ts";
|
|
4
|
+
import type { AgentExecuteOptions, RawAgentEvent } from "../base.ts";
|
|
5
|
+
import { BaseAgent } from "../base.ts";
|
|
6
|
+
import { configFingerprint } from "../config-fingerprint.ts";
|
|
7
|
+
|
|
8
|
+
const CAPABILITIES: AgentCapabilities = {
|
|
9
|
+
multiTurn: true,
|
|
10
|
+
sessionResume: true,
|
|
11
|
+
// A Codex thread's model/effort is fixed at creation; changing them restarts it.
|
|
12
|
+
modelSwitch: "restart-session",
|
|
13
|
+
thinkingLevels: true,
|
|
14
|
+
images: false,
|
|
15
|
+
mcpServers: false,
|
|
16
|
+
// Codex exec mode has no approval callback — the sandbox is the gate.
|
|
17
|
+
permissionRequests: false,
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export interface LiveThread {
|
|
21
|
+
thread: Thread;
|
|
22
|
+
model?: string;
|
|
23
|
+
thinkingLevel?: string;
|
|
24
|
+
cwd: string;
|
|
25
|
+
permissionMode?: PermissionMode;
|
|
26
|
+
credentialsFingerprint: string;
|
|
27
|
+
systemPromptAppend?: string;
|
|
28
|
+
additionalDirectoriesFingerprint: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function credentialsFingerprint(options: Pick<AgentExecuteOptions, "apiKey" | "env">): string {
|
|
32
|
+
return configFingerprint({ apiKey: options.apiKey, env: options.env });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Sandbox surface identity — a changed directory set must restart the thread. */
|
|
36
|
+
function directoriesFingerprint(
|
|
37
|
+
options: Pick<AgentExecuteOptions, "additionalDirectories">,
|
|
38
|
+
): string {
|
|
39
|
+
// Order and duplicates don't change the sandbox surface — don't churn threads.
|
|
40
|
+
return configFingerprint([...new Set(options.additionalDirectories ?? [])].sort());
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Instance identity: credentials plus constructor-level config. The SDK fixes
|
|
45
|
+
* `developer_instructions` at `new Codex(...)`, so a different append needs a
|
|
46
|
+
* different instance; the thread restarts too (`codexThreadCompatible`) but
|
|
47
|
+
* keeps its context via the auto-resume path — restart-with-resume, matching
|
|
48
|
+
* the claude-code policy for systemPromptAppend changes.
|
|
49
|
+
*/
|
|
50
|
+
function instanceFingerprint(
|
|
51
|
+
options: Pick<AgentExecuteOptions, "apiKey" | "env" | "systemPromptAppend">,
|
|
52
|
+
): string {
|
|
53
|
+
return configFingerprint({
|
|
54
|
+
apiKey: options.apiKey,
|
|
55
|
+
env: options.env,
|
|
56
|
+
// One equality convention with codexThreadCompatible: absent == empty.
|
|
57
|
+
systemPromptAppend: options.systemPromptAppend ?? "",
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function codexThreadCompatible(
|
|
62
|
+
existing: LiveThread | undefined,
|
|
63
|
+
options: AgentExecuteOptions,
|
|
64
|
+
): boolean {
|
|
65
|
+
return Boolean(
|
|
66
|
+
existing &&
|
|
67
|
+
existing.cwd === options.cwd &&
|
|
68
|
+
existing.model === options.model &&
|
|
69
|
+
existing.thinkingLevel === options.thinkingLevel &&
|
|
70
|
+
existing.permissionMode === options.permissionMode &&
|
|
71
|
+
existing.credentialsFingerprint === credentialsFingerprint(options) &&
|
|
72
|
+
(existing.systemPromptAppend ?? "") === (options.systemPromptAppend ?? "") &&
|
|
73
|
+
existing.additionalDirectoriesFingerprint === directoriesFingerprint(options) &&
|
|
74
|
+
(!options.resumeSessionId || options.resumeSessionId === existing.thread.id),
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function mapSandbox(mode: PermissionMode | undefined): ThreadOptions["sandboxMode"] {
|
|
79
|
+
switch (mode) {
|
|
80
|
+
case "bypassPermissions":
|
|
81
|
+
return "danger-full-access";
|
|
82
|
+
case "acceptEdits":
|
|
83
|
+
case "dontAsk":
|
|
84
|
+
// Never-ask posture, but inside the normal sandbox — danger-full is
|
|
85
|
+
// reserved for the explicitly dangerous mode.
|
|
86
|
+
return "workspace-write";
|
|
87
|
+
default:
|
|
88
|
+
return "read-only";
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function toCodexInput(input: AgentInput): string {
|
|
93
|
+
if (typeof input === "string") return input;
|
|
94
|
+
return input
|
|
95
|
+
.filter((p) => p.type === "text")
|
|
96
|
+
.map((p) => (p.type === "text" ? p.text : ""))
|
|
97
|
+
.join("\n\n");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function threadOptions(options: AgentExecuteOptions): ThreadOptions {
|
|
101
|
+
const opts: ThreadOptions = {
|
|
102
|
+
workingDirectory: options.cwd,
|
|
103
|
+
skipGitRepoCheck: true,
|
|
104
|
+
approvalPolicy: "never",
|
|
105
|
+
sandboxMode: mapSandbox(options.permissionMode),
|
|
106
|
+
...(options.additionalDirectories?.length
|
|
107
|
+
? { additionalDirectories: options.additionalDirectories }
|
|
108
|
+
: {}),
|
|
109
|
+
};
|
|
110
|
+
if (options.model) opts.model = options.model;
|
|
111
|
+
if (options.thinkingLevel)
|
|
112
|
+
opts.modelReasoningEffort = codexReasoningEffort(options.thinkingLevel);
|
|
113
|
+
return opts;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export interface CodexSdkAgentOptions {
|
|
117
|
+
/**
|
|
118
|
+
* Resolve the `codex` binary to spawn (typically the provisioner's,
|
|
119
|
+
* memoized). `undefined` defers to the SDK's own lookup. Operator-level
|
|
120
|
+
* only — never sourced from the wire.
|
|
121
|
+
*/
|
|
122
|
+
resolveCliPath?: () => Promise<string | undefined>;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Codex harness backed by `@openai/codex-sdk` (which runs the `codex` CLI in
|
|
127
|
+
* exec mode). A `Thread` is reused per logical session for multi-turn; model or
|
|
128
|
+
* effort changes recreate it (restart-session). Resume across processes uses
|
|
129
|
+
* `resumeThread(nativeId)`. Streams at item granularity — the adapter derives
|
|
130
|
+
* text deltas by diffing successive item snapshots.
|
|
131
|
+
*/
|
|
132
|
+
export class CodexSdkAgent extends BaseAgent {
|
|
133
|
+
readonly harness = "codex-sdk" as const;
|
|
134
|
+
readonly capabilities = CAPABILITIES;
|
|
135
|
+
private readonly threads = new Map<string, LiveThread>();
|
|
136
|
+
// Instance-level config (credentials AND developer_instructions) is fixed
|
|
137
|
+
// at construction, so one instance per distinct (apiKey, env, append) —
|
|
138
|
+
// keying by a fingerprint prevents one session's key or instructions from
|
|
139
|
+
// bleeding into another's.
|
|
140
|
+
private readonly codexByCreds = new Map<string, Codex>();
|
|
141
|
+
|
|
142
|
+
constructor(private readonly agentOptions: CodexSdkAgentOptions = {}) {
|
|
143
|
+
super();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
private async getCodex(options: AgentExecuteOptions): Promise<Codex> {
|
|
147
|
+
const fingerprint = instanceFingerprint(options);
|
|
148
|
+
const cached = this.codexByCreds.get(fingerprint);
|
|
149
|
+
if (cached) return cached;
|
|
150
|
+
const codexPathOverride = await this.agentOptions.resolveCliPath?.();
|
|
151
|
+
const { Codex } = await import("@openai/codex-sdk");
|
|
152
|
+
const codex = new Codex({
|
|
153
|
+
...(codexPathOverride ? { codexPathOverride } : {}),
|
|
154
|
+
...(options.apiKey ? { apiKey: options.apiKey } : {}),
|
|
155
|
+
...(options.env ? { env: { ...process.env, ...options.env } as Record<string, string> } : {}),
|
|
156
|
+
...(options.systemPromptAppend
|
|
157
|
+
? { config: { developer_instructions: options.systemPromptAppend } }
|
|
158
|
+
: {}),
|
|
159
|
+
});
|
|
160
|
+
this.codexByCreds.set(fingerprint, codex);
|
|
161
|
+
return codex;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
private async resolveThread(options: AgentExecuteOptions): Promise<LiveThread> {
|
|
165
|
+
const existing = this.threads.get(options.sessionId);
|
|
166
|
+
const effort = options.thinkingLevel;
|
|
167
|
+
if (existing && codexThreadCompatible(existing, options)) return existing;
|
|
168
|
+
|
|
169
|
+
const codex = await this.getCodex(options);
|
|
170
|
+
const fingerprint = credentialsFingerprint(options);
|
|
171
|
+
// Preserve context within the same cwd/credential boundary (mirrors the
|
|
172
|
+
// claude-code restart policy); an explicit resumeSessionId always wins.
|
|
173
|
+
const resumeThreadId =
|
|
174
|
+
options.resumeSessionId ??
|
|
175
|
+
(existing && existing.cwd === options.cwd && existing.credentialsFingerprint === fingerprint
|
|
176
|
+
? existing.thread.id
|
|
177
|
+
: undefined);
|
|
178
|
+
const thread = resumeThreadId
|
|
179
|
+
? codex.resumeThread(resumeThreadId, threadOptions(options))
|
|
180
|
+
: codex.startThread(threadOptions(options));
|
|
181
|
+
const live: LiveThread = {
|
|
182
|
+
thread,
|
|
183
|
+
model: options.model,
|
|
184
|
+
thinkingLevel: effort,
|
|
185
|
+
cwd: options.cwd,
|
|
186
|
+
permissionMode: options.permissionMode,
|
|
187
|
+
credentialsFingerprint: fingerprint,
|
|
188
|
+
systemPromptAppend: options.systemPromptAppend,
|
|
189
|
+
additionalDirectoriesFingerprint: directoriesFingerprint(options),
|
|
190
|
+
};
|
|
191
|
+
this.threads.set(options.sessionId, live);
|
|
192
|
+
return live;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async *execute(
|
|
196
|
+
input: AgentInput,
|
|
197
|
+
options: AgentExecuteOptions,
|
|
198
|
+
): AsyncIterableIterator<RawAgentEvent> {
|
|
199
|
+
const controller = this.trackTurn(options.sessionId, options.signal);
|
|
200
|
+
try {
|
|
201
|
+
const live = await this.resolveThread(options);
|
|
202
|
+
// Codex keeps the thread id on resume, so honored = same id came back.
|
|
203
|
+
const resumeInfo = (id: string) => ({ resumed: id === options.resumeSessionId });
|
|
204
|
+
if (live.thread.id) options.onNativeSession?.(live.thread.id, resumeInfo(live.thread.id));
|
|
205
|
+
|
|
206
|
+
const { events } = await live.thread.runStreamed(toCodexInput(input), {
|
|
207
|
+
signal: controller.signal,
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
let reported = Boolean(live.thread.id);
|
|
211
|
+
for await (const event of events as AsyncGenerator<ThreadEvent>) {
|
|
212
|
+
if (!reported && event.type === "thread.started" && event.thread_id) {
|
|
213
|
+
reported = true;
|
|
214
|
+
options.onNativeSession?.(event.thread_id, resumeInfo(event.thread_id));
|
|
215
|
+
}
|
|
216
|
+
yield event as RawAgentEvent;
|
|
217
|
+
}
|
|
218
|
+
// thread.id is populated once the turn has started.
|
|
219
|
+
if (!reported && live.thread.id)
|
|
220
|
+
options.onNativeSession?.(live.thread.id, resumeInfo(live.thread.id));
|
|
221
|
+
} finally {
|
|
222
|
+
this.endTurn(options.sessionId, controller);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
override async release(sessionId: string): Promise<void> {
|
|
227
|
+
await super.release(sessionId);
|
|
228
|
+
this.threads.delete(sessionId);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
override async terminateAll(): Promise<void> {
|
|
232
|
+
await super.terminateAll();
|
|
233
|
+
this.threads.clear();
|
|
234
|
+
this.codexByCreds.clear();
|
|
235
|
+
}
|
|
236
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
function canonicalize(value: unknown): unknown {
|
|
4
|
+
if (Array.isArray(value)) return value.map(canonicalize);
|
|
5
|
+
if (value && typeof value === "object") {
|
|
6
|
+
return Object.fromEntries(
|
|
7
|
+
Object.entries(value as Record<string, unknown>)
|
|
8
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
9
|
+
.map(([key, item]) => [key, canonicalize(item)]),
|
|
10
|
+
);
|
|
11
|
+
}
|
|
12
|
+
return value;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Stable, non-reversible identity for config that may contain credentials. */
|
|
16
|
+
export function configFingerprint(value: unknown): string {
|
|
17
|
+
const canonical = JSON.stringify(canonicalize(value)) ?? "undefined";
|
|
18
|
+
return createHash("sha256").update(canonical).digest("hex");
|
|
19
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Maps a raw harness error into a stable category, so callers can react
|
|
3
|
+
* (retry on `network`/`rate_limit`, surface `auth` prominently, treat `abort`
|
|
4
|
+
* as success). Distilled from deus-machine's lifecycle classifier.
|
|
5
|
+
*/
|
|
6
|
+
export const ERROR_CATEGORIES = [
|
|
7
|
+
"auth",
|
|
8
|
+
"rate_limit",
|
|
9
|
+
"context_limit",
|
|
10
|
+
"network",
|
|
11
|
+
"abort",
|
|
12
|
+
"process_exit",
|
|
13
|
+
"usage_limit",
|
|
14
|
+
"unknown",
|
|
15
|
+
] as const;
|
|
16
|
+
export type ErrorCategory = (typeof ERROR_CATEGORIES)[number];
|
|
17
|
+
|
|
18
|
+
const RULES: ReadonlyArray<[ErrorCategory, readonly string[]]> = [
|
|
19
|
+
["abort", ["aborted", "aborterror", "cancelled", "canceled", "interrupted", "sigint"]],
|
|
20
|
+
[
|
|
21
|
+
"auth",
|
|
22
|
+
[
|
|
23
|
+
"unauthorized",
|
|
24
|
+
"401",
|
|
25
|
+
"authentication",
|
|
26
|
+
"invalid api key",
|
|
27
|
+
"invalid_api_key",
|
|
28
|
+
"not logged in",
|
|
29
|
+
"login required",
|
|
30
|
+
"forbidden",
|
|
31
|
+
"403",
|
|
32
|
+
],
|
|
33
|
+
],
|
|
34
|
+
["rate_limit", ["rate limit", "rate_limit", "429", "too many requests"]],
|
|
35
|
+
["usage_limit", ["usage limit", "quota", "insufficient_quota", "billing", "credit"]],
|
|
36
|
+
["context_limit", ["context length", "context_length", "maximum context", "too many tokens"]],
|
|
37
|
+
[
|
|
38
|
+
"network",
|
|
39
|
+
[
|
|
40
|
+
"econnrefused",
|
|
41
|
+
"econnreset",
|
|
42
|
+
"etimedout",
|
|
43
|
+
"enotfound",
|
|
44
|
+
"network",
|
|
45
|
+
"fetch failed",
|
|
46
|
+
"socket hang up",
|
|
47
|
+
],
|
|
48
|
+
],
|
|
49
|
+
["process_exit", ["exited with code", "process exited", "spawn", "enoent", "killed"]],
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
export function classifyError(error: unknown): ErrorCategory {
|
|
53
|
+
const message = (error instanceof Error ? error.message : String(error)).toLowerCase();
|
|
54
|
+
for (const [category, needles] of RULES) {
|
|
55
|
+
if (needles.some((n) => message.includes(n))) return category;
|
|
56
|
+
}
|
|
57
|
+
return "unknown";
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Whether a turn can plausibly be retried after this error. */
|
|
61
|
+
export function isRecoverable(category: ErrorCategory): boolean {
|
|
62
|
+
return category === "rate_limit" || category === "network";
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Abort is a user action, not a failure to report. */
|
|
66
|
+
export function isCancellation(error: unknown): boolean {
|
|
67
|
+
return classifyError(error) === "abort";
|
|
68
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { AgentHarness } from "../../protocol/index.ts";
|
|
2
|
+
import { HarnessNotFoundError } from "../utils/errors.ts";
|
|
3
|
+
import type { Agent } from "./base.ts";
|
|
4
|
+
import type { AdapterFactory } from "./types.ts";
|
|
5
|
+
|
|
6
|
+
/** Pairs each harness with its agent implementation and event adapter. */
|
|
7
|
+
export class AgentRegistry {
|
|
8
|
+
private readonly agents = new Map<AgentHarness, Agent>();
|
|
9
|
+
private readonly adapters = new Map<AgentHarness, AdapterFactory>();
|
|
10
|
+
|
|
11
|
+
register(agent: Agent, adapter: AdapterFactory): this {
|
|
12
|
+
this.agents.set(agent.harness, agent);
|
|
13
|
+
this.adapters.set(agent.harness, adapter);
|
|
14
|
+
return this;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
getAgent(harness: AgentHarness): Agent {
|
|
18
|
+
const agent = this.agents.get(harness);
|
|
19
|
+
if (!agent) throw new HarnessNotFoundError(harness);
|
|
20
|
+
return agent;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
getAdapter(harness: AgentHarness): AdapterFactory {
|
|
24
|
+
const adapter = this.adapters.get(harness);
|
|
25
|
+
if (!adapter) throw new HarnessNotFoundError(harness);
|
|
26
|
+
return adapter;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
has(harness: AgentHarness): boolean {
|
|
30
|
+
return this.agents.has(harness);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
list(): AgentHarness[] {
|
|
34
|
+
return [...this.agents.keys()];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async terminateAll(): Promise<void> {
|
|
38
|
+
await Promise.all([...this.agents.values()].map((a) => a.terminateAll()));
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/** The slice of a session a SessionStore manages: a closable client + idle timer. */
|
|
2
|
+
export interface StoredSession {
|
|
3
|
+
client: { closed: boolean; close(): void };
|
|
4
|
+
idleTimer?: ReturnType<typeof setTimeout>;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Per-logical-session bookkeeping shared by subprocess-backed harnesses
|
|
9
|
+
* (codex-app-server, acp): the sessionId → session map, idle-eviction timers,
|
|
10
|
+
* self-healing when a client dies underneath us, and whole-store teardown.
|
|
11
|
+
* Harnesses own what a session *is*; this owns when one goes away.
|
|
12
|
+
*/
|
|
13
|
+
export class SessionStore<S extends StoredSession> {
|
|
14
|
+
private readonly sessions = new Map<string, S>();
|
|
15
|
+
|
|
16
|
+
get(sessionId: string): S | undefined {
|
|
17
|
+
return this.sessions.get(sessionId);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
set(sessionId: string, session: S): void {
|
|
21
|
+
this.sessions.set(sessionId, session);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
values(): IterableIterator<S> {
|
|
25
|
+
return this.sessions.values();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Self-heal: drop the mapping when this exact client closed underneath us. */
|
|
29
|
+
dropIfCurrent(sessionId: string, client: S["client"]): void {
|
|
30
|
+
const current = this.sessions.get(sessionId);
|
|
31
|
+
if (current && current.client === client) {
|
|
32
|
+
this.clearIdle(current);
|
|
33
|
+
this.sessions.delete(sessionId);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Tear down one session: cancel its idle timer, unmap it, close its client. */
|
|
38
|
+
close(sessionId: string): void {
|
|
39
|
+
const session = this.sessions.get(sessionId);
|
|
40
|
+
if (!session) return;
|
|
41
|
+
this.clearIdle(session);
|
|
42
|
+
this.sessions.delete(sessionId);
|
|
43
|
+
session.client.close();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Tear down every session (process shutdown). */
|
|
47
|
+
closeAll(): void {
|
|
48
|
+
for (const session of this.sessions.values()) {
|
|
49
|
+
this.clearIdle(session);
|
|
50
|
+
session.client.close();
|
|
51
|
+
}
|
|
52
|
+
this.sessions.clear();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Shut the session down after `idleMs` unless another turn claims it first. */
|
|
56
|
+
armIdle(sessionId: string, session: S, idleMs: number): void {
|
|
57
|
+
this.clearIdle(session);
|
|
58
|
+
session.idleTimer = setTimeout(() => {
|
|
59
|
+
if (this.sessions.get(sessionId) !== session) return;
|
|
60
|
+
this.sessions.delete(sessionId);
|
|
61
|
+
session.client.close();
|
|
62
|
+
}, idleMs);
|
|
63
|
+
session.idleTimer.unref?.();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
clearIdle(session: S): void {
|
|
67
|
+
if (session.idleTimer) {
|
|
68
|
+
clearTimeout(session.idleTimer);
|
|
69
|
+
session.idleTimer = undefined;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|