@agentstep/agent-sdk 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +45 -0
- package/src/auth/middleware.ts +38 -0
- package/src/backends/claude/args.ts +88 -0
- package/src/backends/claude/index.ts +193 -0
- package/src/backends/claude/permission-hook.ts +152 -0
- package/src/backends/claude/tool-bridge.ts +211 -0
- package/src/backends/claude/translator.ts +209 -0
- package/src/backends/claude/wrapper-script.ts +45 -0
- package/src/backends/codex/args.ts +69 -0
- package/src/backends/codex/auth.ts +35 -0
- package/src/backends/codex/index.ts +57 -0
- package/src/backends/codex/setup.ts +37 -0
- package/src/backends/codex/translator.ts +223 -0
- package/src/backends/codex/wrapper-script.ts +26 -0
- package/src/backends/factory/args.ts +45 -0
- package/src/backends/factory/auth.ts +30 -0
- package/src/backends/factory/index.ts +56 -0
- package/src/backends/factory/setup.ts +34 -0
- package/src/backends/factory/translator.ts +139 -0
- package/src/backends/factory/wrapper-script.ts +33 -0
- package/src/backends/gemini/args.ts +44 -0
- package/src/backends/gemini/auth.ts +30 -0
- package/src/backends/gemini/index.ts +53 -0
- package/src/backends/gemini/setup.ts +34 -0
- package/src/backends/gemini/translator.ts +139 -0
- package/src/backends/gemini/wrapper-script.ts +26 -0
- package/src/backends/opencode/args.ts +53 -0
- package/src/backends/opencode/auth.ts +53 -0
- package/src/backends/opencode/index.ts +70 -0
- package/src/backends/opencode/mcp.ts +67 -0
- package/src/backends/opencode/setup.ts +54 -0
- package/src/backends/opencode/translator.ts +168 -0
- package/src/backends/opencode/wrapper-script.ts +46 -0
- package/src/backends/registry.ts +38 -0
- package/src/backends/shared/ndjson.ts +29 -0
- package/src/backends/shared/translator-types.ts +69 -0
- package/src/backends/shared/wrap-prompt.ts +17 -0
- package/src/backends/types.ts +85 -0
- package/src/config/index.ts +95 -0
- package/src/db/agents.ts +185 -0
- package/src/db/api_keys.ts +78 -0
- package/src/db/batch.ts +142 -0
- package/src/db/client.ts +81 -0
- package/src/db/environments.ts +127 -0
- package/src/db/events.ts +208 -0
- package/src/db/memory.ts +143 -0
- package/src/db/migrations.ts +295 -0
- package/src/db/proxy.ts +37 -0
- package/src/db/sessions.ts +295 -0
- package/src/db/vaults.ts +110 -0
- package/src/errors.ts +53 -0
- package/src/handlers/agents.ts +194 -0
- package/src/handlers/batch.ts +41 -0
- package/src/handlers/docs.ts +87 -0
- package/src/handlers/environments.ts +154 -0
- package/src/handlers/events.ts +234 -0
- package/src/handlers/index.ts +12 -0
- package/src/handlers/memory.ts +141 -0
- package/src/handlers/openapi.ts +14 -0
- package/src/handlers/sessions.ts +223 -0
- package/src/handlers/stream.ts +76 -0
- package/src/handlers/threads.ts +26 -0
- package/src/handlers/ui/app.js +984 -0
- package/src/handlers/ui/index.html +112 -0
- package/src/handlers/ui/style.css +164 -0
- package/src/handlers/ui.ts +1281 -0
- package/src/handlers/vaults.ts +99 -0
- package/src/http.ts +35 -0
- package/src/index.ts +104 -0
- package/src/init.ts +227 -0
- package/src/openapi/registry.ts +8 -0
- package/src/openapi/schemas.ts +625 -0
- package/src/openapi/spec.ts +691 -0
- package/src/providers/apple.ts +220 -0
- package/src/providers/daytona.ts +217 -0
- package/src/providers/docker.ts +264 -0
- package/src/providers/e2b.ts +203 -0
- package/src/providers/fly.ts +276 -0
- package/src/providers/modal.ts +222 -0
- package/src/providers/podman.ts +206 -0
- package/src/providers/registry.ts +28 -0
- package/src/providers/shared.ts +11 -0
- package/src/providers/sprites.ts +55 -0
- package/src/providers/types.ts +73 -0
- package/src/providers/vercel.ts +208 -0
- package/src/proxy/forward.ts +111 -0
- package/src/queue/index.ts +111 -0
- package/src/sessions/actor.ts +53 -0
- package/src/sessions/bus.ts +155 -0
- package/src/sessions/driver.ts +818 -0
- package/src/sessions/grader.ts +120 -0
- package/src/sessions/interrupt.ts +14 -0
- package/src/sessions/sweeper.ts +136 -0
- package/src/sessions/threads.ts +126 -0
- package/src/sessions/tools.ts +50 -0
- package/src/shutdown.ts +78 -0
- package/src/sprite/client.ts +294 -0
- package/src/sprite/exec.ts +161 -0
- package/src/sprite/lifecycle.ts +339 -0
- package/src/sprite/pool.ts +65 -0
- package/src/sprite/setup.ts +159 -0
- package/src/state.ts +61 -0
- package/src/types.ts +339 -0
- package/src/util/clock.ts +7 -0
- package/src/util/ids.ts +11 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gemini backend: drives Google's `gemini -p` on sprites.dev containers.
|
|
3
|
+
*
|
|
4
|
+
* Gemini CLI uses `-p` for prompt mode (like claude) and accepts the prompt
|
|
5
|
+
* on stdin. The wrapper script reads env vars then pipes remaining stdin to
|
|
6
|
+
* gemini.
|
|
7
|
+
*
|
|
8
|
+
* Custom tool re-entry is NOT supported by gemini — gemini has no equivalent
|
|
9
|
+
* of claude's --input-format stream-json. buildTurn rejects
|
|
10
|
+
* toolResults.length > 0 with an invalid_request_error.
|
|
11
|
+
*/
|
|
12
|
+
import { ApiError } from "../../errors";
|
|
13
|
+
import type { Backend, BuildTurnInput, BuildTurnResult } from "../types";
|
|
14
|
+
import type { TranslatorOptions } from "../shared/translator-types";
|
|
15
|
+
import { wrapPromptWithSystem } from "../shared/wrap-prompt";
|
|
16
|
+
import { buildGeminiArgs } from "./args";
|
|
17
|
+
import { buildGeminiAuthEnv, validateGeminiRuntime } from "./auth";
|
|
18
|
+
import { createGeminiTranslator } from "./translator";
|
|
19
|
+
import { GEMINI_WRAPPER_PATH } from "./wrapper-script";
|
|
20
|
+
import { prepareGeminiOnSprite } from "./setup";
|
|
21
|
+
|
|
22
|
+
function buildTurn(input: BuildTurnInput): BuildTurnResult {
|
|
23
|
+
const { agent, backendSessionId, promptText, toolResults } = input;
|
|
24
|
+
if (toolResults.length > 0) {
|
|
25
|
+
throw new ApiError(
|
|
26
|
+
400,
|
|
27
|
+
"invalid_request_error",
|
|
28
|
+
"gemini backend does not support user.custom_tool_result re-entry in v1",
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
const argv = buildGeminiArgs({ agent, backendSessionId });
|
|
32
|
+
const env = buildGeminiAuthEnv();
|
|
33
|
+
const wrappedPrompt = wrapPromptWithSystem(promptText, agent.system);
|
|
34
|
+
return { argv, env, stdin: wrappedPrompt };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const geminiBackend: Backend = {
|
|
38
|
+
name: "gemini" as Backend["name"],
|
|
39
|
+
wrapperPath: GEMINI_WRAPPER_PATH,
|
|
40
|
+
buildTurn,
|
|
41
|
+
createTranslator: (opts: TranslatorOptions) => createGeminiTranslator(opts),
|
|
42
|
+
prepareOnSprite: (name, provider) => prepareGeminiOnSprite(name, provider),
|
|
43
|
+
|
|
44
|
+
validateRuntime: validateGeminiRuntime,
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export {
|
|
48
|
+
buildGeminiArgs,
|
|
49
|
+
buildGeminiAuthEnv,
|
|
50
|
+
createGeminiTranslator,
|
|
51
|
+
prepareGeminiOnSprite,
|
|
52
|
+
GEMINI_WRAPPER_PATH,
|
|
53
|
+
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Install gemini CLI on a freshly-created sprite.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors codex/setup.ts with the same sentinel + symlink pattern.
|
|
5
|
+
* Gemini CLI is installed via npm from the @google/gemini-cli package.
|
|
6
|
+
*/
|
|
7
|
+
import type { ContainerProvider } from "../../providers/types";
|
|
8
|
+
import { installGeminiWrapper } from "./wrapper-script";
|
|
9
|
+
|
|
10
|
+
const SENTINEL_NAME = ".claude-agents-gemini-installed";
|
|
11
|
+
|
|
12
|
+
export async function prepareGeminiOnSprite(spriteName: string, provider: ContainerProvider): Promise<void> {
|
|
13
|
+
await installGeminiWrapper(spriteName, provider);
|
|
14
|
+
|
|
15
|
+
const script = [
|
|
16
|
+
"set -euo pipefail",
|
|
17
|
+
`SENTINEL="$HOME/${SENTINEL_NAME}"`,
|
|
18
|
+
'if [ -f "$SENTINEL" ]; then exit 0; fi',
|
|
19
|
+
"npm install -g @google/gemini-cli",
|
|
20
|
+
"PREFIX=$(npm config get prefix)",
|
|
21
|
+
'if [ "$PREFIX" != "/usr/local" ]; then ln -sf "$PREFIX/bin/gemini" /usr/local/bin/gemini; fi',
|
|
22
|
+
'/usr/local/bin/gemini --version || $PREFIX/bin/gemini --version',
|
|
23
|
+
'touch "$SENTINEL"',
|
|
24
|
+
].join(" && ");
|
|
25
|
+
|
|
26
|
+
const result = await provider.exec(spriteName, ["bash", "-c", script], {
|
|
27
|
+
timeoutMs: 5 * 60_000,
|
|
28
|
+
});
|
|
29
|
+
if (result.exit_code !== 0) {
|
|
30
|
+
throw new Error(
|
|
31
|
+
`gemini install failed (${result.exit_code}): ${result.stderr.slice(0, 500)}`,
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stateful translator: Gemini CLI stream-json NDJSON -> Managed Agents events.
|
|
3
|
+
*
|
|
4
|
+
* Gemini CLI event model (emitted by `gemini -p --output-format stream-json`):
|
|
5
|
+
* - `init` — session init with `session_id` and `model`
|
|
6
|
+
* - `message` — assistant text message (role: "assistant", content: string)
|
|
7
|
+
* - `tool_use` — tool invocation (tool_name, tool_id, parameters)
|
|
8
|
+
* - `tool_result` — tool output (tool_id, output, is_error?)
|
|
9
|
+
* - `result` — end of turn with status and usage stats
|
|
10
|
+
*
|
|
11
|
+
* Maps to Managed Agents events using the same Translator interface as other
|
|
12
|
+
* backends.
|
|
13
|
+
*/
|
|
14
|
+
import type {
|
|
15
|
+
ToolClass,
|
|
16
|
+
TranslatedEvent,
|
|
17
|
+
Translator,
|
|
18
|
+
TranslatorOptions,
|
|
19
|
+
TurnResult,
|
|
20
|
+
} from "../shared/translator-types";
|
|
21
|
+
|
|
22
|
+
export function createGeminiTranslator(opts: TranslatorOptions): Translator {
|
|
23
|
+
const toolClass = new Map<string, ToolClass>();
|
|
24
|
+
let sessionId: string | null = null;
|
|
25
|
+
let lastText = "";
|
|
26
|
+
let sawCustom = false;
|
|
27
|
+
|
|
28
|
+
// Usage from the result event
|
|
29
|
+
let inputTokens = 0;
|
|
30
|
+
let outputTokens = 0;
|
|
31
|
+
let costUsd = 0;
|
|
32
|
+
let numTurns = 0;
|
|
33
|
+
let sawResult = false;
|
|
34
|
+
|
|
35
|
+
function classify(name: string): ToolClass {
|
|
36
|
+
if (opts.customToolNames.has(name)) return "custom";
|
|
37
|
+
return "builtin";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function translate(raw: Record<string, unknown>): TranslatedEvent[] {
|
|
41
|
+
const out: TranslatedEvent[] = [];
|
|
42
|
+
if (!raw || typeof raw !== "object") return out;
|
|
43
|
+
const type = String(raw.type ?? "");
|
|
44
|
+
|
|
45
|
+
if (type === "init") {
|
|
46
|
+
if (typeof raw.session_id === "string") sessionId = raw.session_id;
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (type === "message" && raw.role === "assistant") {
|
|
51
|
+
const content = typeof raw.content === "string" ? raw.content : "";
|
|
52
|
+
if (content) {
|
|
53
|
+
lastText = content;
|
|
54
|
+
out.push({
|
|
55
|
+
type: "agent.message",
|
|
56
|
+
payload: { content: [{ type: "text", text: content }] },
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (type === "tool_use") {
|
|
63
|
+
const toolName = String(raw.tool_name ?? "unknown");
|
|
64
|
+
const toolId = String(raw.tool_id ?? "");
|
|
65
|
+
const parameters = (raw.parameters ?? {}) as Record<string, unknown>;
|
|
66
|
+
|
|
67
|
+
const cls = classify(toolName);
|
|
68
|
+
toolClass.set(toolId, cls);
|
|
69
|
+
if (cls === "custom") sawCustom = true;
|
|
70
|
+
|
|
71
|
+
const useType = cls === "custom" ? "agent.custom_tool_use" : "agent.tool_use";
|
|
72
|
+
out.push({
|
|
73
|
+
type: useType,
|
|
74
|
+
payload: {
|
|
75
|
+
tool_use_id: toolId,
|
|
76
|
+
name: toolName,
|
|
77
|
+
input: parameters,
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (type === "tool_result") {
|
|
84
|
+
const toolId = String(raw.tool_id ?? "");
|
|
85
|
+
const output = typeof raw.output === "string" ? raw.output : JSON.stringify(raw.output ?? "");
|
|
86
|
+
const isError = raw.is_error === true;
|
|
87
|
+
const cls = toolClass.get(toolId);
|
|
88
|
+
|
|
89
|
+
// Only emit tool_result for builtin tools — custom tools are handled
|
|
90
|
+
// by the client via user.custom_tool_result
|
|
91
|
+
if (cls !== "custom") {
|
|
92
|
+
out.push({
|
|
93
|
+
type: "agent.tool_result",
|
|
94
|
+
payload: {
|
|
95
|
+
tool_use_id: toolId,
|
|
96
|
+
content: output,
|
|
97
|
+
is_error: isError,
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (type === "result") {
|
|
105
|
+
sawResult = true;
|
|
106
|
+
const stats = (raw.stats ?? {}) as Record<string, unknown>;
|
|
107
|
+
if (typeof stats.input_tokens === "number") inputTokens = stats.input_tokens;
|
|
108
|
+
if (typeof stats.output_tokens === "number") outputTokens = stats.output_tokens;
|
|
109
|
+
if (typeof stats.cost_usd === "number") costUsd = stats.cost_usd;
|
|
110
|
+
if (typeof stats.num_turns === "number") numTurns = stats.num_turns;
|
|
111
|
+
return out;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Unknown event type — drop silently, translator is forward-compatible.
|
|
115
|
+
return out;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function getTurnResult(): TurnResult | null {
|
|
119
|
+
if (!sawResult && !lastText) return null;
|
|
120
|
+
return {
|
|
121
|
+
stopReason: sawCustom ? "custom_tool_call" : "end_turn",
|
|
122
|
+
usage: {
|
|
123
|
+
input_tokens: inputTokens,
|
|
124
|
+
output_tokens: outputTokens,
|
|
125
|
+
cache_read_input_tokens: 0,
|
|
126
|
+
cache_creation_input_tokens: 0,
|
|
127
|
+
cost_usd: costUsd,
|
|
128
|
+
},
|
|
129
|
+
num_turns: numTurns || 1,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
translate,
|
|
135
|
+
getBackendSessionId: () => sessionId,
|
|
136
|
+
getTurnResult,
|
|
137
|
+
sawCustomToolUse: () => sawCustom,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sprite wrapper script for gemini.
|
|
3
|
+
*
|
|
4
|
+
* Same structure as the claude/codex wrapper: gemini accepts the prompt on
|
|
5
|
+
* stdin via `-p`, so the wrapper reads env vars from stdin until a blank
|
|
6
|
+
* line, then execs gemini with the remaining stdin piped through as the
|
|
7
|
+
* prompt.
|
|
8
|
+
*/
|
|
9
|
+
import type { ContainerProvider } from "../../providers/types";
|
|
10
|
+
|
|
11
|
+
export const GEMINI_WRAPPER_PATH = "/tmp/.gemini-wrapper";
|
|
12
|
+
|
|
13
|
+
const SPRITE_WRAPPER_SCRIPT = [
|
|
14
|
+
"#!/bin/bash",
|
|
15
|
+
'while IFS= read -r line; do [ -z "$line" ] && break; export "$line"; done',
|
|
16
|
+
'exec gemini "$@"',
|
|
17
|
+
].join("\n");
|
|
18
|
+
|
|
19
|
+
export async function installGeminiWrapper(spriteName: string, provider: ContainerProvider): Promise<void> {
|
|
20
|
+
const escaped = SPRITE_WRAPPER_SCRIPT.replace(/'/g, "'\\''");
|
|
21
|
+
await provider.exec(spriteName, [
|
|
22
|
+
"bash",
|
|
23
|
+
"-c",
|
|
24
|
+
`printf '%s' '${escaped}' > ${GEMINI_WRAPPER_PATH} && chmod +x ${GEMINI_WRAPPER_PATH}`,
|
|
25
|
+
]);
|
|
26
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build the `opencode run` argv for one turn.
|
|
3
|
+
*
|
|
4
|
+
* Ported from
|
|
5
|
+
*
|
|
6
|
+
*
|
|
7
|
+
* Opencode-specific constraints:
|
|
8
|
+
* - No --max-turns (opencode has no equivalent; silently ignored)
|
|
9
|
+
* - No --allowed-tools / --disallowed-tools (tools are configured via
|
|
10
|
+
* ~/.opencode.json permissions, not per-agent CLI flags — agents with
|
|
11
|
+
* a non-empty `tools` field are rejected at create time)
|
|
12
|
+
* - No --permission-mode (opencode `run` is non-interactive by design)
|
|
13
|
+
* - No --system-prompt flag — system prompt is wrapped into the user
|
|
14
|
+
* prompt text via `wrapOpencodePrompt`
|
|
15
|
+
* - No --mcp-config flag — MCP config is delivered via the
|
|
16
|
+
* OPENCODE_CONFIG_CONTENT env var (see mcp.ts)
|
|
17
|
+
*/
|
|
18
|
+
import type { Agent } from "../../types";
|
|
19
|
+
|
|
20
|
+
export interface BuildOpencodeArgsInput {
|
|
21
|
+
agent: Agent;
|
|
22
|
+
/** Prior turn's opencode sessionID, if any, for --session resume */
|
|
23
|
+
backendSessionId: string | null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function buildOpencodeArgs(input: BuildOpencodeArgsInput): string[] {
|
|
27
|
+
const args = ["run", "--format", "json"];
|
|
28
|
+
if (input.backendSessionId) {
|
|
29
|
+
args.push("--session", input.backendSessionId);
|
|
30
|
+
}
|
|
31
|
+
if (input.agent.model) {
|
|
32
|
+
args.push("--model", input.agent.model);
|
|
33
|
+
}
|
|
34
|
+
return args;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Wrap the user prompt with an optional system prompt prefix.
|
|
39
|
+
*
|
|
40
|
+
* Opencode's `run` subcommand has no `--system-prompt` flag, so
|
|
41
|
+
* prepends the system prompt to the user message with a
|
|
42
|
+
* separator, exactly as ported here.
|
|
43
|
+
*
|
|
44
|
+
* Verbatim from
|
|
45
|
+
*
|
|
46
|
+
*/
|
|
47
|
+
export function wrapOpencodePrompt(
|
|
48
|
+
prompt: string,
|
|
49
|
+
systemPrompt: string | null | undefined,
|
|
50
|
+
): string {
|
|
51
|
+
if (!systemPrompt) return prompt;
|
|
52
|
+
return `Instructions: ${systemPrompt}\n\n---\n\n${prompt}`;
|
|
53
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auth env + create-time validation for the opencode backend.
|
|
3
|
+
*
|
|
4
|
+
* Opencode is multi-provider: it reads `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`,
|
|
5
|
+
* and other provider-specific env vars at startup and routes model calls
|
|
6
|
+
* based on the `provider/model` prefix in the `--model` flag. We forward
|
|
7
|
+
* every provider key we know about; opencode picks the right one based on
|
|
8
|
+
* the agent's model.
|
|
9
|
+
*
|
|
10
|
+
* Opencode refuses `sk-ant-oat*` OAuth tokens per Anthropic ToS. See
|
|
11
|
+
*
|
|
12
|
+
* — opencode provider only forwards `ANTHROPIC_API_KEY`
|
|
13
|
+
* and warns on OAuth tokens. We enforce the "at least one provider key"
|
|
14
|
+
* invariant at agent create time (and belt-and-braces at first turn) so
|
|
15
|
+
* the user doesn't discover the mismatch inside an opaque opencode error
|
|
16
|
+
* stream.
|
|
17
|
+
*
|
|
18
|
+
* Spike (2026-04-10) verified on a real sprite that opencode picks
|
|
19
|
+
* up `OPENAI_API_KEY` as an env var without any config file.
|
|
20
|
+
*/
|
|
21
|
+
import { getConfig } from "../../config";
|
|
22
|
+
|
|
23
|
+
export function buildOpencodeAuthEnv(): Record<string, string> {
|
|
24
|
+
const cfg = getConfig();
|
|
25
|
+
const env: Record<string, string> = {};
|
|
26
|
+
if (cfg.anthropicApiKey) {
|
|
27
|
+
env.ANTHROPIC_API_KEY = cfg.anthropicApiKey;
|
|
28
|
+
}
|
|
29
|
+
if (cfg.openAiApiKey) {
|
|
30
|
+
env.OPENAI_API_KEY = cfg.openAiApiKey;
|
|
31
|
+
}
|
|
32
|
+
if (cfg.claudeToken && !cfg.anthropicApiKey) {
|
|
33
|
+
console.warn(
|
|
34
|
+
"[opencode] CLAUDE_CODE_OAUTH_TOKEN cannot drive opencode — set ANTHROPIC_API_KEY",
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
return env;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Returns null if opencode can run with the current config, or an error
|
|
42
|
+
* message if it can't. Used both at agent create time (`validateAgentCreation`
|
|
43
|
+
* hook) and at first-turn time (`validateRuntime` hook).
|
|
44
|
+
*
|
|
45
|
+
* Opencode is multi-provider so we accept either a valid Anthropic key or
|
|
46
|
+
* an OpenAI key. The agent's `model` field is still `provider/model` shaped
|
|
47
|
+
* (e.g. `openai/gpt-4o-mini`) and opencode routes based on the prefix.
|
|
48
|
+
*/
|
|
49
|
+
export function validateOpencodeRuntime(): string | null {
|
|
50
|
+
const cfg = getConfig();
|
|
51
|
+
if (cfg.anthropicApiKey || cfg.openAiApiKey) return null;
|
|
52
|
+
return "opencode backend requires at least one provider key: ANTHROPIC_API_KEY or OPENAI_API_KEY (opencode does not accept sk-ant-oat OAuth tokens per Anthropic ToS)";
|
|
53
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Opencode backend: drives sst/opencode-ai's `opencode run` on sprites.dev
|
|
3
|
+
* containers.
|
|
4
|
+
*
|
|
5
|
+
* Ported from
|
|
6
|
+
*
|
|
7
|
+
* (the opencode provider), adapted for our sprite-only
|
|
8
|
+
* execution model. Opencompletions only ran opencode in its local backend;
|
|
9
|
+
* this adapter is the first time opencode runs inside a sprites.dev sprite,
|
|
10
|
+
* so the wrapper script + install flow are new (see wrapper-script.ts and
|
|
11
|
+
* setup.ts).
|
|
12
|
+
*
|
|
13
|
+
* Custom tool re-entry (the stream-json user frame path claude uses) is NOT
|
|
14
|
+
* supported by opencode — `opencode run` has no equivalent input format.
|
|
15
|
+
* buildTurn rejects `toolResults.length > 0` with an invalid_request_error.
|
|
16
|
+
*/
|
|
17
|
+
import { ApiError } from "../../errors";
|
|
18
|
+
import type { Backend, BuildTurnInput, BuildTurnResult } from "../types";
|
|
19
|
+
import type { TranslatorOptions } from "../shared/translator-types";
|
|
20
|
+
import { wrapPromptWithSystem } from "../shared/wrap-prompt";
|
|
21
|
+
import { buildOpencodeArgs } from "./args";
|
|
22
|
+
import { buildOpencodeAuthEnv, validateOpencodeRuntime } from "./auth";
|
|
23
|
+
import { buildOpencodeMcpEnv } from "./mcp";
|
|
24
|
+
import { createOpencodeTranslator } from "./translator";
|
|
25
|
+
import { OPENCODE_WRAPPER_PATH, installOpencodeWrapper } from "./wrapper-script";
|
|
26
|
+
import { prepareOpencodeOnSprite } from "./setup";
|
|
27
|
+
|
|
28
|
+
function buildTurn(input: BuildTurnInput): BuildTurnResult {
|
|
29
|
+
const { agent, backendSessionId, promptText, toolResults } = input;
|
|
30
|
+
|
|
31
|
+
if (toolResults.length > 0) {
|
|
32
|
+
throw new ApiError(
|
|
33
|
+
400,
|
|
34
|
+
"invalid_request_error",
|
|
35
|
+
"opencode backend does not support user.custom_tool_result re-entry in v1",
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const argv = buildOpencodeArgs({ agent, backendSessionId });
|
|
40
|
+
const wrappedPrompt = wrapPromptWithSystem(promptText, agent.system);
|
|
41
|
+
const env = {
|
|
42
|
+
...buildOpencodeAuthEnv(),
|
|
43
|
+
...buildOpencodeMcpEnv(agent),
|
|
44
|
+
};
|
|
45
|
+
// stdin is the raw wrapped prompt — the driver prepends the env block.
|
|
46
|
+
// The opencode wrapper script captures this via PROMPT=$(cat) and
|
|
47
|
+
// re-passes it to `opencode` as a trailing positional argv.
|
|
48
|
+
return { argv, env, stdin: wrappedPrompt };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export const opencodeBackend: Backend = {
|
|
52
|
+
name: "opencode",
|
|
53
|
+
wrapperPath: OPENCODE_WRAPPER_PATH,
|
|
54
|
+
buildTurn,
|
|
55
|
+
createTranslator: (opts: TranslatorOptions) => createOpencodeTranslator(opts),
|
|
56
|
+
prepareOnSprite: (name, provider) => prepareOpencodeOnSprite(name, provider),
|
|
57
|
+
|
|
58
|
+
validateRuntime: validateOpencodeRuntime,
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
// Re-exports for tests and other modules
|
|
62
|
+
export {
|
|
63
|
+
buildOpencodeArgs,
|
|
64
|
+
buildOpencodeAuthEnv,
|
|
65
|
+
buildOpencodeMcpEnv,
|
|
66
|
+
createOpencodeTranslator,
|
|
67
|
+
installOpencodeWrapper,
|
|
68
|
+
prepareOpencodeOnSprite,
|
|
69
|
+
OPENCODE_WRAPPER_PATH,
|
|
70
|
+
};
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP config translation for opencode.
|
|
3
|
+
*
|
|
4
|
+
* Opencode loads its MCP config from the `OPENCODE_CONFIG_CONTENT` env var
|
|
5
|
+
* (not from a `--mcp-config` CLI flag). The JSON shape is slightly
|
|
6
|
+
* different from claude's:
|
|
7
|
+
*
|
|
8
|
+
* Claude: Opencode:
|
|
9
|
+
* type: "stdio" ---> type: "local", command: [cmd, ...args]
|
|
10
|
+
* type: "http" ---> type: "remote"
|
|
11
|
+
* type: "sse" ---> type: "remote"
|
|
12
|
+
*
|
|
13
|
+
* Ported verbatim from
|
|
14
|
+
*
|
|
15
|
+
*/
|
|
16
|
+
import type { Agent, McpServerConfig } from "../../types";
|
|
17
|
+
|
|
18
|
+
/** Opencode's MCP server config shape (distinct from claude's). */
|
|
19
|
+
export interface OpencodeMcpServer {
|
|
20
|
+
type?: "local" | "remote";
|
|
21
|
+
url?: string;
|
|
22
|
+
command?: string | string[];
|
|
23
|
+
headers?: Record<string, string>;
|
|
24
|
+
env?: Record<string, string>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function mcpConfigToOpencode(
|
|
28
|
+
mcpConfig: Record<string, McpServerConfig>,
|
|
29
|
+
): Record<string, OpencodeMcpServer> {
|
|
30
|
+
const mcp: Record<string, OpencodeMcpServer> = {};
|
|
31
|
+
|
|
32
|
+
for (const [name, server] of Object.entries(mcpConfig)) {
|
|
33
|
+
const entry: OpencodeMcpServer = {
|
|
34
|
+
url: server.url,
|
|
35
|
+
headers: server.headers,
|
|
36
|
+
env: server.env,
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
if (server.type === "stdio") {
|
|
40
|
+
entry.type = "local";
|
|
41
|
+
// stdio uses command:string + args:array; opencode wants command:array
|
|
42
|
+
if (typeof server.command === "string") {
|
|
43
|
+
entry.command = [server.command, ...(server.args || [])];
|
|
44
|
+
} else if (Array.isArray(server.command)) {
|
|
45
|
+
entry.command = [...server.command, ...(server.args || [])];
|
|
46
|
+
}
|
|
47
|
+
} else if (server.type === "http" || server.type === "sse") {
|
|
48
|
+
entry.type = "remote";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
mcp[name] = entry;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return mcp;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Return env vars that carry the agent's MCP config to opencode.
|
|
59
|
+
* Returns an empty map if the agent has no MCP servers.
|
|
60
|
+
*/
|
|
61
|
+
export function buildOpencodeMcpEnv(agent: Agent): Record<string, string> {
|
|
62
|
+
if (!agent.mcp_servers || Object.keys(agent.mcp_servers).length === 0) {
|
|
63
|
+
return {};
|
|
64
|
+
}
|
|
65
|
+
const opencodeMcp = mcpConfigToOpencode(agent.mcp_servers);
|
|
66
|
+
return { OPENCODE_CONFIG_CONTENT: JSON.stringify({ mcp: opencodeMcp }) };
|
|
67
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Install opencode on a freshly-created sprite.
|
|
3
|
+
*
|
|
4
|
+
* Idempotent via a sentinel file at OPENCODE_INSTALLED_SENTINEL. First call
|
|
5
|
+
* takes ~10 seconds on sprites.dev's base image.
|
|
6
|
+
* Subsequent calls for the same sprite are instant (sentinel short-circuit).
|
|
7
|
+
*
|
|
8
|
+
* findings:
|
|
9
|
+
* - sprites.dev base image has node v22.20.0 pre-installed via nvm at
|
|
10
|
+
* `/.sprite/languages/node/nvm/versions/node/v22.20.0/`
|
|
11
|
+
* - `npm install -g opencode-ai` succeeds in 9s and installs opencode-ai@1.4.1
|
|
12
|
+
* - npm creates the symlink `<prefix>/bin/opencode` but that dir is NOT on
|
|
13
|
+
* the default PATH (only `/.sprite/bin` is)
|
|
14
|
+
* - Workaround: `ln -sf $(npm config get prefix)/bin/opencode /usr/local/bin/opencode`
|
|
15
|
+
* works without sudo and makes `opencode` directly invokable as
|
|
16
|
+
* `/usr/local/bin/opencode` (which IS on PATH)
|
|
17
|
+
*/
|
|
18
|
+
import type { ContainerProvider } from "../../providers/types";
|
|
19
|
+
import { installOpencodeWrapper } from "./wrapper-script";
|
|
20
|
+
|
|
21
|
+
// Use $HOME-relative sentinel so it works on any container runtime
|
|
22
|
+
// (sprites.dev HOME=/home/sprite, Docker/Apple HOME=/root or /home/node)
|
|
23
|
+
const SENTINEL_NAME = ".claude-agents-opencode-installed";
|
|
24
|
+
|
|
25
|
+
export async function prepareOpencodeOnSprite(spriteName: string, provider: ContainerProvider): Promise<void> {
|
|
26
|
+
await installOpencodeWrapper(spriteName, provider);
|
|
27
|
+
|
|
28
|
+
// Install opencode binary, sentinel-guarded for idempotency.
|
|
29
|
+
// Uses /usr/local/bin/opencode for verification (not `which`) because
|
|
30
|
+
// some container exec contexts have a minimal PATH that doesn't include
|
|
31
|
+
// /usr/local/bin even though the binary is there.
|
|
32
|
+
const script = [
|
|
33
|
+
"set -euo pipefail",
|
|
34
|
+
`SENTINEL="$HOME/${SENTINEL_NAME}"`,
|
|
35
|
+
'if [ -f "$SENTINEL" ]; then exit 0; fi',
|
|
36
|
+
"npm install -g opencode-ai",
|
|
37
|
+
"PREFIX=$(npm config get prefix)",
|
|
38
|
+
// Only symlink if npm prefix differs from /usr/local — otherwise the
|
|
39
|
+
// symlink overwrites npm's existing binary with a circular self-reference.
|
|
40
|
+
// This happens on Docker/Apple containers where PREFIX=/usr/local.
|
|
41
|
+
'if [ "$PREFIX" != "/usr/local" ]; then ln -sf "$PREFIX/bin/opencode" /usr/local/bin/opencode; fi',
|
|
42
|
+
'/usr/local/bin/opencode --version || $PREFIX/bin/opencode --version',
|
|
43
|
+
'touch "$SENTINEL"',
|
|
44
|
+
].join(" && ");
|
|
45
|
+
|
|
46
|
+
const result = await provider.exec(spriteName, ["bash", "-c", script], {
|
|
47
|
+
timeoutMs: 5 * 60_000, // 5 minutes — cold install typically <30s but leave headroom
|
|
48
|
+
});
|
|
49
|
+
if (result.exit_code !== 0) {
|
|
50
|
+
throw new Error(
|
|
51
|
+
`opencode install failed (${result.exit_code}): ${result.stderr.slice(0, 500)}`,
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
}
|