@fusionkit/adapter-ai-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.
@@ -0,0 +1,303 @@
1
+ import { execFileSync, spawnSync } from "node:child_process";
2
+ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
3
+ import { dirname, relative, resolve } from "node:path";
4
+ import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
5
+ import { generateText, jsonSchema, stepCountIs, tool } from "ai";
6
+ import { emitTrace, modelCallFinishedPayload, modelCallStartedPayload, newSpanId, TRACE_CANDIDATE_HEADER, TRACE_ID_HEADER, TRACE_SPAN_HEADER } from "@fusionkit/protocol";
7
+ function asString(value) {
8
+ return typeof value === "string" ? value : undefined;
9
+ }
10
+ /** Normalize one AI SDK step's content parts into trajectory steps. */
11
+ function extractSteps(content) {
12
+ const out = [];
13
+ for (const part of content) {
14
+ const text = asString(part.text);
15
+ if ((part.type === "reasoning" || part.type === "text") && text !== undefined && text.length > 0) {
16
+ out.push({ type: "reasoning", text: truncate(text) });
17
+ }
18
+ else if (part.type === "tool-call") {
19
+ out.push({
20
+ type: "tool_call",
21
+ ...(asString(part.toolName) !== undefined ? { tool_name: asString(part.toolName) } : {}),
22
+ ...(asString(part.toolCallId) !== undefined ? { tool_call_id: asString(part.toolCallId) } : {}),
23
+ tool_input: truncate(stringifyOutput(part.input), 600)
24
+ });
25
+ }
26
+ else if (part.type === "tool-result") {
27
+ out.push({
28
+ type: "observation",
29
+ ...(asString(part.toolCallId) !== undefined ? { tool_call_id: asString(part.toolCallId) } : {}),
30
+ text: truncate(stringifyOutput(part.output), MAX_TOOL_OUTPUT)
31
+ });
32
+ }
33
+ }
34
+ return out;
35
+ }
36
+ const AGENT_SYSTEM_PROMPT = "You are a coding agent working in a real repository checkout. Use the tools to inspect " +
37
+ "the repository and, when the task requires it, modify files and run commands or tests. " +
38
+ "Answer questions directly from what you read. For code changes, make the edit with " +
39
+ "write_file and verify with run (e.g. the test command). When you are done, stop and give " +
40
+ "a concise final message describing the answer or the change you made.";
41
+ const MAX_FILE_BYTES = 24_000;
42
+ const MAX_TOOL_OUTPUT = 4_000;
43
+ function truncate(text, limit = 2_000) {
44
+ return text.length <= limit ? text : `${text.slice(0, limit)}...[truncated]`;
45
+ }
46
+ /** Resolve a tool-supplied path inside the worktree, refusing escapes. */
47
+ function safeResolve(root, candidate) {
48
+ const resolved = resolve(root, candidate);
49
+ const rel = relative(root, resolved);
50
+ if (rel.startsWith("..") || resolve(root, rel) !== resolved) {
51
+ throw new Error(`path escapes the worktree: ${candidate}`);
52
+ }
53
+ return resolved;
54
+ }
55
+ function listDir(root, dir) {
56
+ const target = safeResolve(root, dir || ".");
57
+ const entries = readdirSync(target, { withFileTypes: true })
58
+ .filter((entry) => entry.name !== ".git")
59
+ .map((entry) => (entry.isDirectory() ? `${entry.name}/` : entry.name))
60
+ .sort();
61
+ return entries.join("\n") || "(empty)";
62
+ }
63
+ function grepRepo(root, pattern) {
64
+ const result = spawnSync("git", ["grep", "-n", "-I", "-e", pattern], {
65
+ cwd: root,
66
+ encoding: "utf8",
67
+ timeout: 30_000
68
+ });
69
+ const output = [result.stdout, result.stderr].filter(Boolean).join("\n").trim();
70
+ return truncate(output || "(no matches)", MAX_TOOL_OUTPUT);
71
+ }
72
+ function runCommand(root, command, timeoutMs) {
73
+ const env = { ...process.env };
74
+ delete env.NODE_TEST_CONTEXT;
75
+ const result = spawnSync(command, { cwd: root, encoding: "utf8", timeout: timeoutMs, shell: true, env });
76
+ const body = [result.stdout, result.stderr].filter(Boolean).join("\n");
77
+ return truncate(`exit_code=${result.status ?? "null"}\n${body}`, MAX_TOOL_OUTPUT);
78
+ }
79
+ function worktreeTools(root, commandTimeoutMs) {
80
+ return {
81
+ read_file: tool({
82
+ description: "Read a UTF-8 text file from the repository.",
83
+ inputSchema: jsonSchema({
84
+ type: "object",
85
+ properties: { path: { type: "string", description: "Path relative to the repo root." } },
86
+ required: ["path"],
87
+ additionalProperties: false
88
+ }),
89
+ execute: async ({ path }) => {
90
+ const target = safeResolve(root, path);
91
+ if (!existsSync(target))
92
+ return `(no such file: ${path})`;
93
+ if (statSync(target).size > MAX_FILE_BYTES)
94
+ return `(file too large: ${path})`;
95
+ return readFileSync(target, "utf8");
96
+ }
97
+ }),
98
+ list_dir: tool({
99
+ description: "List the entries of a directory in the repository.",
100
+ inputSchema: jsonSchema({
101
+ type: "object",
102
+ properties: { path: { type: "string", description: "Directory relative to the repo root." } },
103
+ additionalProperties: false
104
+ }),
105
+ execute: async ({ path }) => listDir(root, path ?? ".")
106
+ }),
107
+ grep: tool({
108
+ description: "Search the repository for a pattern (git grep).",
109
+ inputSchema: jsonSchema({
110
+ type: "object",
111
+ properties: { pattern: { type: "string", description: "Regex/text to search for." } },
112
+ required: ["pattern"],
113
+ additionalProperties: false
114
+ }),
115
+ execute: async ({ pattern }) => grepRepo(root, pattern)
116
+ }),
117
+ write_file: tool({
118
+ description: "Create or overwrite a file in the repository with the given contents.",
119
+ inputSchema: jsonSchema({
120
+ type: "object",
121
+ properties: {
122
+ path: { type: "string", description: "Path relative to the repo root." },
123
+ contents: { type: "string", description: "Full file contents to write." }
124
+ },
125
+ required: ["path", "contents"],
126
+ additionalProperties: false
127
+ }),
128
+ execute: async ({ path, contents }) => {
129
+ const target = safeResolve(root, path);
130
+ mkdirSync(dirname(target), { recursive: true });
131
+ writeFileSync(target, contents);
132
+ return `wrote ${contents.length} bytes to ${path}`;
133
+ }
134
+ }),
135
+ run: tool({
136
+ description: "Run a shell command (e.g. the test command) in the repository root.",
137
+ inputSchema: jsonSchema({
138
+ type: "object",
139
+ properties: { command: { type: "string", description: "Shell command to run." } },
140
+ required: ["command"],
141
+ additionalProperties: false
142
+ }),
143
+ execute: async ({ command }) => runCommand(root, command, commandTimeoutMs)
144
+ })
145
+ };
146
+ }
147
+ function stringifyOutput(value) {
148
+ if (typeof value === "string")
149
+ return value;
150
+ try {
151
+ return JSON.stringify(value);
152
+ }
153
+ catch {
154
+ return String(value);
155
+ }
156
+ }
157
+ /** Run one panel model as a real agent over the worktree and capture its trajectory. */
158
+ export async function runWorktreeAgent(input) {
159
+ const agentSpan = newSpanId();
160
+ const traceHeaders = input.traceId !== undefined
161
+ ? {
162
+ [TRACE_ID_HEADER]: input.traceId,
163
+ [TRACE_SPAN_HEADER]: agentSpan,
164
+ ...(input.candidateId !== undefined ? { [TRACE_CANDIDATE_HEADER]: input.candidateId } : {})
165
+ }
166
+ : undefined;
167
+ const provider = createOpenAICompatible({
168
+ name: "fusion-panel-agent",
169
+ baseURL: `${input.baseUrl.replace(/\/+$/, "")}/v1`,
170
+ apiKey: input.apiKey ?? "not-needed",
171
+ ...(traceHeaders !== undefined ? { headers: traceHeaders } : {})
172
+ });
173
+ const model = provider(input.model);
174
+ const steps = [];
175
+ let index = 0;
176
+ const push = (step) => {
177
+ const full = { index: index++, ...step };
178
+ steps.push(full);
179
+ return full;
180
+ };
181
+ const emitStep = (step) => {
182
+ if (input.traceId === undefined)
183
+ return;
184
+ emitTrace({
185
+ component: "panel-model",
186
+ event_type: "trajectory.step",
187
+ traceId: input.traceId,
188
+ spanId: agentSpan,
189
+ ...(input.parentSpanId !== undefined ? { parentSpanId: input.parentSpanId } : {}),
190
+ ...(input.candidateId !== undefined ? { candidateId: input.candidateId } : {}),
191
+ modelId: input.model,
192
+ payload: { step }
193
+ });
194
+ };
195
+ const emitModelCall = (eventType, payload) => {
196
+ if (input.traceId === undefined)
197
+ return;
198
+ emitTrace({
199
+ component: "panel-model",
200
+ event_type: eventType,
201
+ traceId: input.traceId,
202
+ spanId: agentSpan,
203
+ ...(input.parentSpanId !== undefined ? { parentSpanId: input.parentSpanId } : {}),
204
+ ...(input.candidateId !== undefined ? { candidateId: input.candidateId } : {}),
205
+ modelId: input.model,
206
+ payload
207
+ });
208
+ };
209
+ const tools = worktreeTools(input.worktree, input.commandTimeoutMs ?? 120_000);
210
+ emitModelCall("model.call.started", modelCallStartedPayload({
211
+ model: input.model,
212
+ systemPrompt: AGENT_SYSTEM_PROMPT,
213
+ prompt: input.prompt,
214
+ tools: Object.keys(tools),
215
+ ...(input.turn !== undefined ? { turn: input.turn } : {})
216
+ }));
217
+ const startedAt = Date.now();
218
+ try {
219
+ const result = await generateText({
220
+ model,
221
+ system: AGENT_SYSTEM_PROMPT,
222
+ prompt: input.prompt,
223
+ tools,
224
+ stopWhen: stepCountIs(input.maxSteps ?? 12),
225
+ onStepFinish: (step) => {
226
+ for (const normalized of extractSteps(step.content)) {
227
+ emitStep(push(normalized));
228
+ }
229
+ },
230
+ ...(input.abortSignal !== undefined ? { abortSignal: input.abortSignal } : {})
231
+ });
232
+ const toolCallCount = steps.filter((step) => step.type === "tool_call").length;
233
+ const finalOutput = result.text ?? "";
234
+ emitStep(push({ type: "output", text: finalOutput }));
235
+ emitModelCall("model.call.finished", modelCallFinishedPayload({
236
+ model: input.model,
237
+ finalOutput,
238
+ finishReason: result.finishReason ?? "stop",
239
+ stepCount: steps.length,
240
+ toolCallCount,
241
+ latencyS: (Date.now() - startedAt) / 1000,
242
+ ...(input.turn !== undefined ? { turn: input.turn } : {}),
243
+ ...(normalizeUsage(result.usage) !== undefined ? { usage: normalizeUsage(result.usage) } : {})
244
+ }));
245
+ return {
246
+ status: "succeeded",
247
+ steps,
248
+ finalOutput,
249
+ finishReason: result.finishReason ?? "stop",
250
+ toolCallCount
251
+ };
252
+ }
253
+ catch (error) {
254
+ const message = error instanceof Error ? error.message : String(error);
255
+ emitStep(push({ type: "output", text: `agent failed: ${message}` }));
256
+ emitModelCall("model.call.finished", modelCallFinishedPayload({
257
+ model: input.model,
258
+ finishReason: "error",
259
+ latencyS: (Date.now() - startedAt) / 1000,
260
+ error: message,
261
+ ...(input.turn !== undefined ? { turn: input.turn } : {})
262
+ }));
263
+ return { status: "failed", steps, finalOutput: `agent failed: ${message}`, finishReason: "error", toolCallCount: 0 };
264
+ }
265
+ }
266
+ /** Map AI SDK usage (field names vary by version) to OpenAI-style token counts. */
267
+ function normalizeUsage(usage) {
268
+ if (typeof usage !== "object" || usage === null)
269
+ return undefined;
270
+ const source = usage;
271
+ const pick = (...keys) => {
272
+ for (const key of keys) {
273
+ if (typeof source[key] === "number")
274
+ return source[key];
275
+ }
276
+ return undefined;
277
+ };
278
+ const prompt = pick("promptTokens", "inputTokens", "prompt_tokens", "input_tokens");
279
+ const completion = pick("completionTokens", "outputTokens", "completion_tokens", "output_tokens");
280
+ const total = pick("totalTokens", "total_tokens") ?? (prompt ?? 0) + (completion ?? 0);
281
+ const out = {};
282
+ if (prompt !== undefined)
283
+ out.prompt_tokens = prompt;
284
+ if (completion !== undefined)
285
+ out.completion_tokens = completion;
286
+ if (total > 0 || prompt !== undefined || completion !== undefined)
287
+ out.total_tokens = total;
288
+ return Object.keys(out).length > 0 ? out : undefined;
289
+ }
290
+ /** Compute the worktree's staged diff against a base ref (for patch evidence). */
291
+ export function worktreeDiff(root, baseGitSha) {
292
+ try {
293
+ execFileSync("git", ["add", "-A"], { cwd: root });
294
+ return execFileSync("git", ["diff", "--cached", "--binary", baseGitSha], {
295
+ cwd: root,
296
+ encoding: "utf8",
297
+ maxBuffer: 16 * 1024 * 1024
298
+ });
299
+ }
300
+ catch {
301
+ return "";
302
+ }
303
+ }
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@fusionkit/adapter-ai-sdk",
3
+ "private": false,
4
+ "version": "0.1.0",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/velum-labs/handoffkit.git",
8
+ "directory": "packages/adapter-ai-sdk"
9
+ },
10
+ "description": "AI SDK adapter for app-owned loops: tool calls execute in governed runner sessions and return with receipts. No durability claim attaches to the caller's loop.",
11
+ "license": "UNLICENSED",
12
+ "type": "module",
13
+ "exports": {
14
+ ".": {
15
+ "types": "./dist/index.d.ts",
16
+ "default": "./dist/index.js"
17
+ }
18
+ },
19
+ "files": [
20
+ "dist"
21
+ ],
22
+ "publishConfig": {
23
+ "registry": "https://registry.npmjs.org",
24
+ "access": "public",
25
+ "provenance": true
26
+ },
27
+ "dependencies": {
28
+ "@ai-sdk/openai-compatible": "2.0.48",
29
+ "@ai-sdk/provider": "3.0.10",
30
+ "ai": "6.0.200",
31
+ "zod": "4.4.3",
32
+ "@fusionkit/handoff": "0.1.0",
33
+ "@fusionkit/sdk": "0.1.0",
34
+ "@fusionkit/protocol": "0.1.0"
35
+ },
36
+ "devDependencies": {
37
+ "@fusionkit/testkit": "0.1.0"
38
+ }
39
+ }