@axiom-lattice/cli-a2a 0.1.5 → 0.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +34 -32
- package/CHANGELOG.md +27 -0
- package/README.md +85 -2
- package/__tests__/opencode-executor.test.ts +631 -31
- package/__tests__/session-store-path.test.ts +40 -0
- package/dist/{chunk-NQIDRU47.mjs → chunk-54SKDK3D.mjs} +78 -32
- package/dist/chunk-54SKDK3D.mjs.map +1 -0
- package/dist/chunk-BSNQOP2W.mjs +350 -0
- package/dist/chunk-BSNQOP2W.mjs.map +1 -0
- package/dist/chunk-DRFFQJII.mjs +195 -0
- package/dist/chunk-DRFFQJII.mjs.map +1 -0
- package/dist/chunk-URJDRXJW.mjs +378 -0
- package/dist/chunk-URJDRXJW.mjs.map +1 -0
- package/dist/{chunk-G7AGL2QA.mjs → chunk-WZB7FBSX.mjs} +23 -5
- package/dist/chunk-WZB7FBSX.mjs.map +1 -0
- package/dist/{chunk-LXL47XMZ.mjs → chunk-YGUPSPZO.mjs} +9 -1
- package/dist/chunk-YGUPSPZO.mjs.map +1 -0
- package/dist/{chunk-35NFMGMS.mjs → chunk-ZP3JNJQJ.mjs} +3 -3
- package/dist/chunk-ZP3JNJQJ.mjs.map +1 -0
- package/dist/cli.js +769 -156
- package/dist/cli.js.map +1 -1
- package/dist/cli.mjs +7 -6
- package/dist/cli.mjs.map +1 -1
- package/dist/executor-LLE5VEFJ.mjs +12 -0
- package/dist/executor-LO7P3KUW.mjs +12 -0
- package/dist/{executor-RVBGAWUF.mjs → executor-OOLGGXHS.mjs} +4 -3
- package/dist/{executors-QIIKBUMJ.mjs → executors-AQHOPGSR.mjs} +2 -2
- package/dist/index.d.mts +92 -21
- package/dist/index.d.ts +92 -21
- package/dist/index.js +731 -123
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +15 -7
- package/package.json +10 -3
- package/runtime/cli-path.cjs +3 -0
- package/runtime/cli-path.d.ts +1 -0
- package/runtime/cli-path.mjs +5 -0
- package/src/config/defaults.ts +3 -1
- package/src/config/types.ts +8 -3
- package/src/executors/claude/client.ts +44 -14
- package/src/executors/claude/executor.ts +65 -30
- package/src/executors/codex/client.ts +239 -16
- package/src/executors/codex/executor.ts +70 -37
- package/src/executors/events.ts +15 -1
- package/src/executors/index.ts +13 -6
- package/src/executors/opencode/client.ts +192 -18
- package/src/executors/opencode/executor.ts +65 -30
- package/src/index.ts +8 -0
- package/src/local-state.ts +53 -0
- package/src/server/agent-card.ts +1 -0
- package/src/server/index.ts +22 -7
- package/src/session-store.ts +262 -0
- package/dist/chunk-35NFMGMS.mjs.map +0 -1
- package/dist/chunk-G7AGL2QA.mjs.map +0 -1
- package/dist/chunk-LXL47XMZ.mjs.map +0 -1
- package/dist/chunk-NQIDRU47.mjs.map +0 -1
- package/dist/chunk-VSZ3DACI.mjs +0 -179
- package/dist/chunk-VSZ3DACI.mjs.map +0 -1
- package/dist/chunk-WNCDOYZS.mjs +0 -187
- package/dist/chunk-WNCDOYZS.mjs.map +0 -1
- package/dist/executor-OWPVDUXH.mjs +0 -11
- package/dist/executor-XWHWUVQ3.mjs +0 -11
- /package/dist/{executor-OWPVDUXH.mjs.map → executor-LLE5VEFJ.mjs.map} +0 -0
- /package/dist/{executor-RVBGAWUF.mjs.map → executor-LO7P3KUW.mjs.map} +0 -0
- /package/dist/{executor-XWHWUVQ3.mjs.map → executor-OOLGGXHS.mjs.map} +0 -0
- /package/dist/{executors-QIIKBUMJ.mjs.map → executors-AQHOPGSR.mjs.map} +0 -0
|
@@ -2,20 +2,23 @@
|
|
|
2
2
|
* Codex A2A Executor
|
|
3
3
|
*
|
|
4
4
|
* Bridges the local Codex CLI to the A2A protocol. Spawns Codex as a
|
|
5
|
-
* child process for each request
|
|
5
|
+
* child process for each request. Supports multi-turn conversation via
|
|
6
|
+
* contextId → sessionId binding through SessionBindingStore.
|
|
6
7
|
*/
|
|
7
8
|
|
|
8
9
|
import type { A2AExecutor } from '../index.js';
|
|
9
10
|
import type { RequestContext, ExecutionEventBus } from '@a2a-js/sdk/server';
|
|
10
|
-
import type { Message as A2AMessage } from '@a2a-js/sdk';
|
|
11
11
|
|
|
12
12
|
import type { AgentConfig } from '../../config/types.js';
|
|
13
|
+
import type { SessionBindingStore } from '../../session-store.js';
|
|
14
|
+
import { generateContextId } from '../../session-store.js';
|
|
13
15
|
import { logger } from '../../logger.js';
|
|
14
16
|
import { CodexClient } from './client.js';
|
|
15
17
|
import {
|
|
16
18
|
publishTask,
|
|
17
19
|
publishStatus,
|
|
18
20
|
publishFinalArtifact,
|
|
21
|
+
extractText,
|
|
19
22
|
} from '../events.js';
|
|
20
23
|
|
|
21
24
|
const log = logger.child('codex:executor');
|
|
@@ -26,9 +29,11 @@ export class CodexExecutor implements A2AExecutor {
|
|
|
26
29
|
private config: Required<AgentConfig>;
|
|
27
30
|
private client: CodexClient | null = null;
|
|
28
31
|
private initialized = false;
|
|
32
|
+
private sessionStore: SessionBindingStore;
|
|
29
33
|
|
|
30
|
-
constructor(config: Required<AgentConfig
|
|
34
|
+
constructor(config: Required<AgentConfig>, sessionStore: SessionBindingStore) {
|
|
31
35
|
this.config = config;
|
|
36
|
+
this.sessionStore = sessionStore;
|
|
32
37
|
}
|
|
33
38
|
|
|
34
39
|
async initialize(): Promise<void> {
|
|
@@ -54,37 +59,75 @@ export class CodexExecutor implements A2AExecutor {
|
|
|
54
59
|
}
|
|
55
60
|
|
|
56
61
|
async execute(ctx: RequestContext, bus: ExecutionEventBus): Promise<void> {
|
|
57
|
-
const { taskId,
|
|
62
|
+
const { taskId, userMessage } = ctx;
|
|
58
63
|
await this.initialize();
|
|
59
64
|
|
|
60
|
-
|
|
61
|
-
// 1. Register task
|
|
62
|
-
if (!task) {
|
|
63
|
-
publishTask(bus, taskId, contextId);
|
|
64
|
-
publishStatus(bus, taskId, contextId, 'submitted');
|
|
65
|
-
}
|
|
65
|
+
const reuseByContext = this.config.session?.reuseByContext ?? true;
|
|
66
66
|
|
|
67
|
-
|
|
68
|
-
|
|
67
|
+
// Resolve contextId: use provided one or generate new
|
|
68
|
+
let contextId = ctx.contextId;
|
|
69
|
+
if (!contextId) {
|
|
70
|
+
contextId = generateContextId();
|
|
71
|
+
log.info('Generated new contextId', { contextId, taskId });
|
|
72
|
+
}
|
|
69
73
|
|
|
70
|
-
|
|
71
|
-
|
|
74
|
+
// Look up existing session binding
|
|
75
|
+
const binding = reuseByContext
|
|
76
|
+
? this.sessionStore.get(contextId, 'codex')
|
|
77
|
+
: null;
|
|
72
78
|
|
|
73
|
-
|
|
79
|
+
const sessionId = binding?.sessionId;
|
|
74
80
|
|
|
75
|
-
|
|
76
|
-
|
|
81
|
+
try {
|
|
82
|
+
// Register task (always include contextId so client can track)
|
|
83
|
+
const existingTask = ctx.task;
|
|
84
|
+
if (!existingTask) {
|
|
85
|
+
publishTask(bus, taskId, contextId);
|
|
86
|
+
publishStatus(bus, taskId, contextId, 'submitted');
|
|
87
|
+
}
|
|
77
88
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
89
|
+
publishStatus(bus, taskId, contextId, 'working');
|
|
90
|
+
|
|
91
|
+
const promptText = extractText(userMessage);
|
|
92
|
+
|
|
93
|
+
// Extract per-request workingDirectory from A2A message metadata
|
|
94
|
+
const userMsg = userMessage as unknown as Record<string, unknown>;
|
|
95
|
+
const workingDirectory = userMsg.metadata &&
|
|
96
|
+
typeof userMsg.metadata === 'object'
|
|
97
|
+
? (userMsg.metadata as Record<string, unknown>).workingDirectory as string | undefined
|
|
98
|
+
: undefined;
|
|
99
|
+
|
|
100
|
+
log.info('Sending prompt to Codex', {
|
|
101
|
+
taskId,
|
|
102
|
+
contextId,
|
|
103
|
+
sessionId: sessionId || '(new)',
|
|
104
|
+
len: promptText.length,
|
|
105
|
+
workingDirectory: workingDirectory || '(default)',
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
const result = await this.client!.execute(promptText, sessionId, workingDirectory);
|
|
109
|
+
|
|
110
|
+
// Save or update session binding
|
|
111
|
+
this.sessionStore.set({
|
|
112
|
+
provider: 'codex',
|
|
113
|
+
contextId,
|
|
114
|
+
sessionId: result.sessionId,
|
|
115
|
+
activeTaskId: undefined,
|
|
116
|
+
activeTaskState: undefined,
|
|
117
|
+
createdAt: binding?.createdAt ?? new Date().toISOString(),
|
|
118
|
+
updatedAt: new Date().toISOString(),
|
|
119
|
+
expiresAt: new Date(Date.now() + (this.config.session?.ttl ?? 24 * 60 * 60 * 1000)).toISOString(),
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// Finalize
|
|
123
|
+
publishFinalArtifact(bus, taskId, contextId, result.text || 'No response from Codex.');
|
|
81
124
|
publishStatus(bus, taskId, contextId, 'completed', undefined, true);
|
|
82
125
|
bus.finished();
|
|
83
|
-
log.info('Task completed', { taskId,
|
|
126
|
+
log.info('Task completed', { taskId, contextId, sessionId: result.sessionId });
|
|
84
127
|
|
|
85
128
|
} catch (error) {
|
|
86
129
|
const msg = (error as Error).message ?? String(error);
|
|
87
|
-
log.error('Execution failed', { taskId, error: msg });
|
|
130
|
+
log.error('Execution failed', { taskId, contextId, error: msg });
|
|
88
131
|
publishStatus(bus, taskId, contextId, 'failed', `Error: ${msg}`, true);
|
|
89
132
|
bus.finished();
|
|
90
133
|
}
|
|
@@ -96,23 +139,13 @@ export class CodexExecutor implements A2AExecutor {
|
|
|
96
139
|
publishStatus(bus, taskId, '', 'canceled', 'Codex task cancelled', true);
|
|
97
140
|
bus.finished();
|
|
98
141
|
}
|
|
99
|
-
|
|
100
|
-
// ── Helpers ─────────────────────────────────────────────────────────────
|
|
101
|
-
|
|
102
|
-
private extractText(message: A2AMessage): string {
|
|
103
|
-
return message.parts
|
|
104
|
-
.filter((p) => {
|
|
105
|
-
const part = p as unknown as Record<string, unknown>;
|
|
106
|
-
const text = part.text as string | undefined;
|
|
107
|
-
return text !== undefined && text !== null;
|
|
108
|
-
})
|
|
109
|
-
.map((p) => (p as unknown as { text: string }).text)
|
|
110
|
-
.join('\n');
|
|
111
|
-
}
|
|
112
142
|
}
|
|
113
143
|
|
|
114
144
|
// ─── Factory ────────────────────────────────────────────────────────────────
|
|
115
145
|
|
|
116
|
-
export function createCodexExecutor(
|
|
117
|
-
|
|
146
|
+
export function createCodexExecutor(
|
|
147
|
+
config: Required<AgentConfig>,
|
|
148
|
+
sessionStore: SessionBindingStore,
|
|
149
|
+
): A2AExecutor {
|
|
150
|
+
return new CodexExecutor(config, sessionStore);
|
|
118
151
|
}
|
package/src/executors/events.ts
CHANGED
|
@@ -6,10 +6,24 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { TaskState } from '@a2a-js/sdk';
|
|
9
|
-
import type { TaskStatusUpdateEvent, TaskArtifactUpdateEvent } from '@a2a-js/sdk';
|
|
9
|
+
import type { TaskStatusUpdateEvent, TaskArtifactUpdateEvent, Message as A2AMessage } from '@a2a-js/sdk';
|
|
10
10
|
import type { ExecutionEventBus } from '@a2a-js/sdk/server';
|
|
11
11
|
import { v4 as uuidv4 } from 'uuid';
|
|
12
12
|
|
|
13
|
+
/**
|
|
14
|
+
* Extract plain text content from an A2A message's parts.
|
|
15
|
+
*/
|
|
16
|
+
export function extractText(message: A2AMessage): string {
|
|
17
|
+
return message.parts
|
|
18
|
+
.filter((p) => {
|
|
19
|
+
const part = p as unknown as Record<string, unknown>;
|
|
20
|
+
const text = part.text as string | undefined;
|
|
21
|
+
return text !== undefined && text !== null;
|
|
22
|
+
})
|
|
23
|
+
.map((p) => (p as unknown as { text: string }).text)
|
|
24
|
+
.join('\n');
|
|
25
|
+
}
|
|
26
|
+
|
|
13
27
|
/**
|
|
14
28
|
* Register a task with the execution event bus before publishing any events.
|
|
15
29
|
*/
|
package/src/executors/index.ts
CHANGED
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import type { AgentExecutor } from '@a2a-js/sdk/server';
|
|
10
|
+
import type { SessionBindingStore } from '../session-store.js';
|
|
11
|
+
import type { AgentConfig, ProviderType } from '../config/types.js';
|
|
10
12
|
|
|
11
13
|
/**
|
|
12
14
|
* Extended executor interface with lifecycle methods.
|
|
@@ -24,12 +26,14 @@ export type { AgentExecutor, RequestContext, ExecutionEventBus } from '@a2a-js/s
|
|
|
24
26
|
export { TaskState } from '@a2a-js/sdk';
|
|
25
27
|
export { v4 as uuidv4 } from 'uuid';
|
|
26
28
|
|
|
27
|
-
import type { AgentConfig, ProviderType } from '../config/types.js';
|
|
28
|
-
|
|
29
29
|
/**
|
|
30
|
-
* Factory function type: given resolved config
|
|
30
|
+
* Factory function type: given resolved config and a session store,
|
|
31
|
+
* produce an executor.
|
|
31
32
|
*/
|
|
32
|
-
export type ExecutorFactory = (
|
|
33
|
+
export type ExecutorFactory = (
|
|
34
|
+
config: Required<AgentConfig>,
|
|
35
|
+
sessionStore: SessionBindingStore,
|
|
36
|
+
) => A2AExecutor;
|
|
33
37
|
|
|
34
38
|
/**
|
|
35
39
|
* Map provider type to executor factory.
|
|
@@ -44,11 +48,14 @@ export function getExecutor(provider: ProviderType): ExecutorFactory | undefined
|
|
|
44
48
|
return registry.get(provider);
|
|
45
49
|
}
|
|
46
50
|
|
|
47
|
-
export function createExecutor(
|
|
51
|
+
export function createExecutor(
|
|
52
|
+
config: Required<AgentConfig>,
|
|
53
|
+
sessionStore: SessionBindingStore,
|
|
54
|
+
): A2AExecutor {
|
|
48
55
|
const factory = registry.get(config.provider);
|
|
49
56
|
if (!factory) {
|
|
50
57
|
const available = Array.from(registry.keys()).join(', ');
|
|
51
58
|
throw new Error(`Unknown provider: ${config.provider}. Available: ${available}`);
|
|
52
59
|
}
|
|
53
|
-
return factory(config);
|
|
60
|
+
return factory(config, sessionStore);
|
|
54
61
|
}
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* OpenCode CLI client wrapper.
|
|
3
3
|
*
|
|
4
|
-
* Spawns the OpenCode CLI as a child process.
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Spawns the OpenCode CLI as a child process. For the first turn, uses
|
|
5
|
+
* `--format json` to discover the session ID, then extracts the response
|
|
6
|
+
* text. For subsequent turns, uses `--session <id>` to continue the
|
|
7
|
+
* conversation with plain text output.
|
|
7
8
|
*/
|
|
8
9
|
|
|
9
10
|
import { spawn } from 'node:child_process';
|
|
@@ -28,6 +29,11 @@ export interface OpenCodeClientConfig {
|
|
|
28
29
|
timeout?: number;
|
|
29
30
|
}
|
|
30
31
|
|
|
32
|
+
export interface OpenCodeExecuteResult {
|
|
33
|
+
text: string;
|
|
34
|
+
sessionId: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
31
37
|
// ─── Client ─────────────────────────────────────────────────────────────────
|
|
32
38
|
|
|
33
39
|
export class OpenCodeClient {
|
|
@@ -35,7 +41,6 @@ export class OpenCodeClient {
|
|
|
35
41
|
|
|
36
42
|
constructor(private config: OpenCodeClientConfig) {}
|
|
37
43
|
|
|
38
|
-
/** Abort the currently running child process (if any). */
|
|
39
44
|
abort(): void {
|
|
40
45
|
if (this.currentChild) {
|
|
41
46
|
this.currentChild.kill('SIGTERM');
|
|
@@ -49,18 +54,38 @@ export class OpenCodeClient {
|
|
|
49
54
|
}
|
|
50
55
|
|
|
51
56
|
/**
|
|
52
|
-
* Execute a prompt via the OpenCode CLI
|
|
57
|
+
* Execute a prompt via the OpenCode CLI.
|
|
58
|
+
*
|
|
59
|
+
* @param prompt - The user prompt text.
|
|
60
|
+
* @param sessionId - If provided, continues the session with `--session`.
|
|
61
|
+
* Otherwise creates a new session using `--format json` to capture the
|
|
62
|
+
* session ID.
|
|
63
|
+
* @param workingDirectory - Optional per-request working directory override.
|
|
64
|
+
* Falls back to config.workdir if not provided.
|
|
53
65
|
*/
|
|
54
|
-
async execute(prompt: string): Promise<
|
|
66
|
+
async execute(prompt: string, sessionId?: string, workingDirectory?: string): Promise<OpenCodeExecuteResult> {
|
|
67
|
+
if (sessionId) {
|
|
68
|
+
const text = await this.runResume(prompt, sessionId, workingDirectory);
|
|
69
|
+
return { text, sessionId };
|
|
70
|
+
}
|
|
71
|
+
return this.runNew(prompt, workingDirectory);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
private resolveWorkdir(override?: string): string {
|
|
75
|
+
return override || this.config.workdir || process.cwd();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ── New session (first turn) ──────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
private async runNew(prompt: string, workingDirectory?: string): Promise<OpenCodeExecuteResult> {
|
|
55
81
|
const cliPath = this.config.cliPath || 'opencode';
|
|
56
82
|
const timeout = this.config.timeout ?? 600_000;
|
|
57
|
-
const MAX_PROMPT = 100_000;
|
|
83
|
+
const MAX_PROMPT = 100_000;
|
|
58
84
|
|
|
59
85
|
if (prompt.length > MAX_PROMPT) {
|
|
60
|
-
throw new Error(`Prompt too large (${prompt.length} chars, max ${MAX_PROMPT})
|
|
86
|
+
throw new Error(`Prompt too large (${prompt.length} chars, max ${MAX_PROMPT}).`);
|
|
61
87
|
}
|
|
62
88
|
|
|
63
|
-
// Only pass essential env vars
|
|
64
89
|
const env: Record<string, string> = {
|
|
65
90
|
PATH: process.env.PATH ?? '',
|
|
66
91
|
HOME: process.env.HOME ?? '',
|
|
@@ -68,13 +93,110 @@ export class OpenCodeClient {
|
|
|
68
93
|
for (const key of ['HTTP_PROXY', 'HTTPS_PROXY', 'NO_PROXY', 'http_proxy', 'https_proxy', 'no_proxy']) {
|
|
69
94
|
if (process.env[key]) env[key] = process.env[key];
|
|
70
95
|
}
|
|
71
|
-
// Forward auth env vars if present
|
|
72
96
|
for (const key of ['OPENAI_API_KEY', 'ANTHROPIC_API_KEY', 'OPENCODE_SERVER_PASSWORD', 'OPENCODE_SERVER_USERNAME']) {
|
|
73
97
|
if (process.env[key]) env[key] = process.env[key];
|
|
74
98
|
}
|
|
75
99
|
|
|
76
|
-
const args: string[] = ['run',
|
|
100
|
+
const args: string[] = ['run', '--format', 'json'];
|
|
101
|
+
if (this.config.model) {
|
|
102
|
+
args.push('--model', this.config.model);
|
|
103
|
+
}
|
|
104
|
+
if (this.config.agent) {
|
|
105
|
+
args.push('--agent', this.config.agent);
|
|
106
|
+
}
|
|
107
|
+
if (this.config.attachUrl) {
|
|
108
|
+
args.push('--attach', this.config.attachUrl);
|
|
109
|
+
}
|
|
110
|
+
args.push('--dangerously-skip-permissions', prompt);
|
|
111
|
+
|
|
112
|
+
const workdir = this.resolveWorkdir(workingDirectory);
|
|
113
|
+
log.info('Spawning opencode (new session, json)', { cliPath, workdir });
|
|
114
|
+
|
|
115
|
+
return new Promise<OpenCodeExecuteResult>((resolve, reject) => {
|
|
116
|
+
const child = spawn(cliPath, args, {
|
|
117
|
+
cwd: workdir,
|
|
118
|
+
env,
|
|
119
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
this.currentChild = child;
|
|
123
|
+
|
|
124
|
+
let stdout = '';
|
|
125
|
+
let stderr = '';
|
|
126
|
+
const MAX_OUTPUT = 10_000_000;
|
|
127
|
+
|
|
128
|
+
const timer = setTimeout(() => {
|
|
129
|
+
child.kill('SIGTERM');
|
|
130
|
+
const forceTimer = setTimeout(() => {
|
|
131
|
+
if (child.exitCode === null) child.kill('SIGKILL');
|
|
132
|
+
}, 5000);
|
|
133
|
+
child.on('close', () => clearTimeout(forceTimer));
|
|
134
|
+
reject(new Error(`OpenCode execution timed out after ${timeout}ms`));
|
|
135
|
+
}, timeout);
|
|
136
|
+
|
|
137
|
+
child.stdout?.on('data', (chunk: Buffer) => {
|
|
138
|
+
stdout += chunk.toString();
|
|
139
|
+
if (stdout.length > MAX_OUTPUT) {
|
|
140
|
+
child.kill('SIGTERM');
|
|
141
|
+
setTimeout(() => { if (child.exitCode === null) child.kill('SIGKILL'); }, 5000);
|
|
142
|
+
}
|
|
143
|
+
});
|
|
77
144
|
|
|
145
|
+
child.stderr?.on('data', (chunk: Buffer) => {
|
|
146
|
+
stderr += chunk.toString();
|
|
147
|
+
log.debug('OpenCode stderr', { text: chunk.toString().trim() });
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
child.on('close', (code) => {
|
|
151
|
+
clearTimeout(timer);
|
|
152
|
+
this.currentChild = null;
|
|
153
|
+
|
|
154
|
+
if (code !== 0) {
|
|
155
|
+
const errMsg = stderr.trim() || stdout.trim() || `OpenCode exited with code ${code}`;
|
|
156
|
+
log.warn('OpenCode non-zero exit', { code, stderr: errMsg });
|
|
157
|
+
reject(new Error(errMsg));
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
try {
|
|
162
|
+
const { text, sessionId } = this.parseJsonEvents(stdout);
|
|
163
|
+
resolve({ text, sessionId });
|
|
164
|
+
} catch (err) {
|
|
165
|
+
reject(new Error(`Failed to parse OpenCode JSON output: ${(err as Error).message}`));
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
child.on('error', (err) => {
|
|
170
|
+
clearTimeout(timer);
|
|
171
|
+
log.error('OpenCode spawn failed', { error: err.message });
|
|
172
|
+
reject(new Error(`Failed to start opencode: ${err.message}. Is it installed?`));
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ── Resume session ────────────────────────────────────────────────────
|
|
178
|
+
|
|
179
|
+
private async runResume(prompt: string, sessionId: string, workingDirectory?: string): Promise<string> {
|
|
180
|
+
const cliPath = this.config.cliPath || 'opencode';
|
|
181
|
+
const timeout = this.config.timeout ?? 600_000;
|
|
182
|
+
const MAX_PROMPT = 100_000;
|
|
183
|
+
|
|
184
|
+
if (prompt.length > MAX_PROMPT) {
|
|
185
|
+
throw new Error(`Prompt too large (${prompt.length} chars, max ${MAX_PROMPT}).`);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const env: Record<string, string> = {
|
|
189
|
+
PATH: process.env.PATH ?? '',
|
|
190
|
+
HOME: process.env.HOME ?? '',
|
|
191
|
+
};
|
|
192
|
+
for (const key of ['HTTP_PROXY', 'HTTPS_PROXY', 'NO_PROXY', 'http_proxy', 'https_proxy', 'no_proxy']) {
|
|
193
|
+
if (process.env[key]) env[key] = process.env[key];
|
|
194
|
+
}
|
|
195
|
+
for (const key of ['OPENAI_API_KEY', 'ANTHROPIC_API_KEY', 'OPENCODE_SERVER_PASSWORD', 'OPENCODE_SERVER_USERNAME']) {
|
|
196
|
+
if (process.env[key]) env[key] = process.env[key];
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const args: string[] = ['run', '--session', sessionId];
|
|
78
200
|
if (this.config.model) {
|
|
79
201
|
args.push('--model', this.config.model);
|
|
80
202
|
}
|
|
@@ -84,16 +206,14 @@ export class OpenCodeClient {
|
|
|
84
206
|
if (this.config.attachUrl) {
|
|
85
207
|
args.push('--attach', this.config.attachUrl);
|
|
86
208
|
}
|
|
87
|
-
|
|
88
|
-
args.push('--format', 'default');
|
|
89
|
-
// Never go interactive
|
|
90
|
-
args.push('--dangerously-skip-permissions');
|
|
209
|
+
args.push('--format', 'default', '--dangerously-skip-permissions', prompt);
|
|
91
210
|
|
|
92
|
-
|
|
211
|
+
const workdir = this.resolveWorkdir(workingDirectory);
|
|
212
|
+
log.info('Spawning opencode (resume)', { sessionId, workdir });
|
|
93
213
|
|
|
94
214
|
return new Promise<string>((resolve, reject) => {
|
|
95
215
|
const child = spawn(cliPath, args, {
|
|
96
|
-
cwd:
|
|
216
|
+
cwd: workdir,
|
|
97
217
|
env,
|
|
98
218
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
99
219
|
});
|
|
@@ -102,7 +222,7 @@ export class OpenCodeClient {
|
|
|
102
222
|
|
|
103
223
|
let stdout = '';
|
|
104
224
|
let stderr = '';
|
|
105
|
-
const MAX_OUTPUT = 10_000_000;
|
|
225
|
+
const MAX_OUTPUT = 10_000_000;
|
|
106
226
|
|
|
107
227
|
const timer = setTimeout(() => {
|
|
108
228
|
child.kill('SIGTERM');
|
|
@@ -146,4 +266,58 @@ export class OpenCodeClient {
|
|
|
146
266
|
});
|
|
147
267
|
});
|
|
148
268
|
}
|
|
269
|
+
|
|
270
|
+
// ── Parser ────────────────────────────────────────────────────────────
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Parse newline-delimited JSON events from OpenCode's `--format json` output.
|
|
274
|
+
* Extracts the sessionID from the first event that contains it, and
|
|
275
|
+
* accumulates text content from relevant events.
|
|
276
|
+
*/
|
|
277
|
+
private parseJsonEvents(stdout: string): { text: string; sessionId: string } {
|
|
278
|
+
const lines = stdout.split('\n').filter((l) => l.trim());
|
|
279
|
+
let sessionId = '';
|
|
280
|
+
const textParts: string[] = [];
|
|
281
|
+
|
|
282
|
+
for (const line of lines) {
|
|
283
|
+
let event: Record<string, unknown>;
|
|
284
|
+
try {
|
|
285
|
+
event = JSON.parse(line);
|
|
286
|
+
} catch {
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Capture sessionID from any event that has it
|
|
291
|
+
if (!sessionId && event.sessionID) {
|
|
292
|
+
sessionId = event.sessionID as string;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Skip error events — they don't contain user-facing text
|
|
296
|
+
if (event.type === 'error') continue;
|
|
297
|
+
|
|
298
|
+
// Extract text from events that have content
|
|
299
|
+
// Common OpenCode event shapes: { type, text }, { type, content }, { message }
|
|
300
|
+
const text = event.text as string | undefined
|
|
301
|
+
|| event.content as string | undefined
|
|
302
|
+
|| event.message as string | undefined;
|
|
303
|
+
|
|
304
|
+
if (text) {
|
|
305
|
+
textParts.push(text);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if (!sessionId) {
|
|
310
|
+
// Fallback: if no sessionID in JSON, the output may be plain text from
|
|
311
|
+
// a non-JSON mode invocation. Return the raw text.
|
|
312
|
+
return {
|
|
313
|
+
text: textParts.join('\n').trim() || stdout.trim(),
|
|
314
|
+
sessionId: '',
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
return {
|
|
319
|
+
text: textParts.join('\n').trim() || 'No response from OpenCode.',
|
|
320
|
+
sessionId,
|
|
321
|
+
};
|
|
322
|
+
}
|
|
149
323
|
}
|
|
@@ -2,21 +2,23 @@
|
|
|
2
2
|
* OpenCode A2A Executor
|
|
3
3
|
*
|
|
4
4
|
* Bridges the local OpenCode CLI to the A2A protocol. Spawns `opencode run`
|
|
5
|
-
* as a child process for each request
|
|
6
|
-
*
|
|
5
|
+
* as a child process for each request. Supports multi-turn conversation
|
|
6
|
+
* via contextId → sessionId binding through SessionBindingStore.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import type { A2AExecutor } from '../index.js';
|
|
10
10
|
import type { RequestContext, ExecutionEventBus } from '@a2a-js/sdk/server';
|
|
11
|
-
import type { Message as A2AMessage } from '@a2a-js/sdk';
|
|
12
11
|
|
|
13
12
|
import type { AgentConfig } from '../../config/types.js';
|
|
13
|
+
import type { SessionBindingStore } from '../../session-store.js';
|
|
14
|
+
import { generateContextId } from '../../session-store.js';
|
|
14
15
|
import { logger } from '../../logger.js';
|
|
15
16
|
import { OpenCodeClient } from './client.js';
|
|
16
17
|
import {
|
|
17
18
|
publishTask,
|
|
18
19
|
publishStatus,
|
|
19
20
|
publishFinalArtifact,
|
|
21
|
+
extractText,
|
|
20
22
|
} from '../events.js';
|
|
21
23
|
|
|
22
24
|
const log = logger.child('opencode:executor');
|
|
@@ -27,9 +29,11 @@ export class OpenCodeExecutor implements A2AExecutor {
|
|
|
27
29
|
private config: Required<AgentConfig>;
|
|
28
30
|
private client: OpenCodeClient | null = null;
|
|
29
31
|
private initialized = false;
|
|
32
|
+
private sessionStore: SessionBindingStore;
|
|
30
33
|
|
|
31
|
-
constructor(config: Required<AgentConfig
|
|
34
|
+
constructor(config: Required<AgentConfig>, sessionStore: SessionBindingStore) {
|
|
32
35
|
this.config = config;
|
|
36
|
+
this.sessionStore = sessionStore;
|
|
33
37
|
}
|
|
34
38
|
|
|
35
39
|
async initialize(): Promise<void> {
|
|
@@ -56,31 +60,70 @@ export class OpenCodeExecutor implements A2AExecutor {
|
|
|
56
60
|
}
|
|
57
61
|
|
|
58
62
|
async execute(ctx: RequestContext, bus: ExecutionEventBus): Promise<void> {
|
|
59
|
-
const { taskId,
|
|
63
|
+
const { taskId, userMessage } = ctx;
|
|
60
64
|
await this.initialize();
|
|
61
65
|
|
|
66
|
+
const reuseByContext = this.config.session?.reuseByContext ?? true;
|
|
67
|
+
|
|
68
|
+
let contextId = ctx.contextId;
|
|
69
|
+
if (!contextId) {
|
|
70
|
+
contextId = generateContextId();
|
|
71
|
+
log.info('Generated new contextId', { contextId, taskId });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const binding = reuseByContext
|
|
75
|
+
? this.sessionStore.get(contextId, 'opencode')
|
|
76
|
+
: null;
|
|
77
|
+
|
|
78
|
+
const sessionId = binding?.sessionId;
|
|
79
|
+
|
|
62
80
|
try {
|
|
63
|
-
|
|
81
|
+
const existingTask = ctx.task;
|
|
82
|
+
if (!existingTask) {
|
|
64
83
|
publishTask(bus, taskId, contextId);
|
|
65
84
|
publishStatus(bus, taskId, contextId, 'submitted');
|
|
66
85
|
}
|
|
67
86
|
|
|
68
|
-
publishStatus(bus, taskId, contextId, 'working'
|
|
69
|
-
|
|
70
|
-
const promptText =
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
87
|
+
publishStatus(bus, taskId, contextId, 'working');
|
|
88
|
+
|
|
89
|
+
const promptText = extractText(userMessage);
|
|
90
|
+
|
|
91
|
+
// Extract per-request workingDirectory from A2A message metadata
|
|
92
|
+
const userMsg = userMessage as unknown as Record<string, unknown>;
|
|
93
|
+
const workingDirectory = userMsg.metadata &&
|
|
94
|
+
typeof userMsg.metadata === 'object'
|
|
95
|
+
? (userMsg.metadata as Record<string, unknown>).workingDirectory as string | undefined
|
|
96
|
+
: undefined;
|
|
97
|
+
|
|
98
|
+
log.info('Sending prompt to OpenCode', {
|
|
99
|
+
taskId,
|
|
100
|
+
contextId,
|
|
101
|
+
sessionId: sessionId || '(new)',
|
|
102
|
+
len: promptText.length,
|
|
103
|
+
workingDirectory: workingDirectory || '(default)',
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
const result = await this.client!.execute(promptText, sessionId, workingDirectory);
|
|
107
|
+
|
|
108
|
+
this.sessionStore.set({
|
|
109
|
+
provider: 'opencode',
|
|
110
|
+
contextId,
|
|
111
|
+
sessionId: result.sessionId,
|
|
112
|
+
activeTaskId: undefined,
|
|
113
|
+
activeTaskState: undefined,
|
|
114
|
+
createdAt: binding?.createdAt ?? new Date().toISOString(),
|
|
115
|
+
updatedAt: new Date().toISOString(),
|
|
116
|
+
expiresAt: new Date(Date.now() + (this.config.session?.ttl ?? 24 * 60 * 60 * 1000)).toISOString(),
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
publishFinalArtifact(bus, taskId, contextId, result.text || 'No response from OpenCode.');
|
|
77
120
|
publishStatus(bus, taskId, contextId, 'completed', undefined, true);
|
|
78
121
|
bus.finished();
|
|
79
|
-
log.info('Task completed', { taskId,
|
|
122
|
+
log.info('Task completed', { taskId, contextId, sessionId: result.sessionId });
|
|
80
123
|
|
|
81
124
|
} catch (error) {
|
|
82
125
|
const msg = (error as Error).message ?? String(error);
|
|
83
|
-
log.error('Execution failed', { taskId, error: msg });
|
|
126
|
+
log.error('Execution failed', { taskId, contextId, error: msg });
|
|
84
127
|
publishStatus(bus, taskId, contextId, 'failed', `Error: ${msg}`, true);
|
|
85
128
|
bus.finished();
|
|
86
129
|
}
|
|
@@ -92,21 +135,13 @@ export class OpenCodeExecutor implements A2AExecutor {
|
|
|
92
135
|
publishStatus(bus, taskId, '', 'canceled', 'OpenCode task cancelled', true);
|
|
93
136
|
bus.finished();
|
|
94
137
|
}
|
|
95
|
-
|
|
96
|
-
private extractText(message: A2AMessage): string {
|
|
97
|
-
return message.parts
|
|
98
|
-
.filter((p) => {
|
|
99
|
-
const part = p as unknown as Record<string, unknown>;
|
|
100
|
-
const text = part.text as string | undefined;
|
|
101
|
-
return text !== undefined && text !== null;
|
|
102
|
-
})
|
|
103
|
-
.map((p) => (p as unknown as { text: string }).text)
|
|
104
|
-
.join('\n');
|
|
105
|
-
}
|
|
106
138
|
}
|
|
107
139
|
|
|
108
140
|
// ─── Factory ────────────────────────────────────────────────────────────────
|
|
109
141
|
|
|
110
|
-
export function createOpenCodeExecutor(
|
|
111
|
-
|
|
142
|
+
export function createOpenCodeExecutor(
|
|
143
|
+
config: Required<AgentConfig>,
|
|
144
|
+
sessionStore: SessionBindingStore,
|
|
145
|
+
): A2AExecutor {
|
|
146
|
+
return new OpenCodeExecutor(config, sessionStore);
|
|
112
147
|
}
|
package/src/index.ts
CHANGED
|
@@ -52,3 +52,11 @@ export type { AgentExecutor, RequestContext, ExecutionEventBus } from './executo
|
|
|
52
52
|
|
|
53
53
|
// Logger
|
|
54
54
|
export { logger, LogLevel } from './logger.js';
|
|
55
|
+
|
|
56
|
+
// Session Store
|
|
57
|
+
export {
|
|
58
|
+
SessionBindingStore,
|
|
59
|
+
generateContextId,
|
|
60
|
+
resolveSessionStorePath,
|
|
61
|
+
} from './session-store.js';
|
|
62
|
+
export type { SessionBinding } from './session-store.js';
|