ocpipe 0.3.1 → 0.3.3
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/DESIGN.md +14 -0
- package/GETTING_STARTED.md +25 -0
- package/README.md +5 -0
- package/package.json +5 -5
- package/src/.tmp/ocpipe-integration-test/mood-analysis_20251231_092022.json +55 -0
- package/src/agent.ts +166 -177
- package/src/parsing.ts +41 -0
- package/src/predict.ts +95 -2
- package/src/types.ts +14 -1
package/DESIGN.md
CHANGED
|
@@ -45,6 +45,20 @@ const AnalyzeCode = signature({
|
|
|
45
45
|
- `field.nullable(field)` - Nullable wrapper
|
|
46
46
|
- `field.custom(zodType, desc?)` - Custom Zod type
|
|
47
47
|
|
|
48
|
+
**Type inference:**
|
|
49
|
+
|
|
50
|
+
Use `InferInputs<S>` and `InferOutputs<S>` to extract TypeScript types from a signature:
|
|
51
|
+
|
|
52
|
+
```typescript
|
|
53
|
+
import { InferInputs, InferOutputs } from 'ocpipe'
|
|
54
|
+
|
|
55
|
+
type AnalyzeInputs = InferInputs<typeof AnalyzeCode>
|
|
56
|
+
// { code: string; language: 'typescript' | 'python' | 'rust' }
|
|
57
|
+
|
|
58
|
+
type AnalyzeOutputs = InferOutputs<typeof AnalyzeCode>
|
|
59
|
+
// { issues: { severity: 'error' | 'warning' | 'info'; message: string; line: number }[]; suggestions: string[]; score: number }
|
|
60
|
+
```
|
|
61
|
+
|
|
48
62
|
### Predict
|
|
49
63
|
|
|
50
64
|
`Predict` bridges a Signature and OpenCode. It handles prompt generation, response parsing, and validation.
|
package/GETTING_STARTED.md
CHANGED
|
@@ -355,6 +355,31 @@ field.enum(['a', 'b'] as const) // 'a' | 'b'
|
|
|
355
355
|
field.optional(field.string()) // string | undefined
|
|
356
356
|
```
|
|
357
357
|
|
|
358
|
+
### Type Inference
|
|
359
|
+
|
|
360
|
+
Use `InferInputs` and `InferOutputs` to extract TypeScript types from a signature:
|
|
361
|
+
|
|
362
|
+
```typescript
|
|
363
|
+
import { signature, field, InferInputs, InferOutputs } from 'ocpipe'
|
|
364
|
+
|
|
365
|
+
const Greet = signature({
|
|
366
|
+
doc: 'Generate a greeting.',
|
|
367
|
+
inputs: { name: field.string('Name to greet') },
|
|
368
|
+
outputs: { greeting: field.string('The greeting message') },
|
|
369
|
+
})
|
|
370
|
+
|
|
371
|
+
// Extract types from the signature
|
|
372
|
+
type GreetInputs = InferInputs<typeof Greet> // { name: string }
|
|
373
|
+
type GreetOutputs = InferOutputs<typeof Greet> // { greeting: string }
|
|
374
|
+
|
|
375
|
+
// Use in functions
|
|
376
|
+
function processGreeting(input: GreetInputs): void {
|
|
377
|
+
console.log(`Processing greeting for: ${input.name}`)
|
|
378
|
+
}
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
This is useful for typing function parameters, return types, or when building generic utilities around signatures.
|
|
382
|
+
|
|
358
383
|
### Complex Modules
|
|
359
384
|
|
|
360
385
|
For modules with multiple predictors or transformed outputs, use the base `Module` class:
|
package/README.md
CHANGED
|
@@ -40,6 +40,11 @@ const pipeline = new Pipeline(
|
|
|
40
40
|
|
|
41
41
|
const result = await pipeline.run(module(Greet), { name: 'World' })
|
|
42
42
|
console.log(result.data.greeting)
|
|
43
|
+
|
|
44
|
+
// Extract types from signatures
|
|
45
|
+
import { InferInputs, InferOutputs } from 'ocpipe'
|
|
46
|
+
type GreetIn = InferInputs<typeof Greet> // { name: string }
|
|
47
|
+
type GreetOut = InferOutputs<typeof Greet> // { greeting: string }
|
|
43
48
|
```
|
|
44
49
|
|
|
45
50
|
OpenCode CLI is bundled — run `bun run opencode` or use your system `opencode` if installed.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ocpipe",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.3",
|
|
4
4
|
"description": "SDK for LLM pipelines with OpenCode and Zod",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -29,20 +29,20 @@
|
|
|
29
29
|
"bun": ">=1.0.0"
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
|
-
"opencode-ai": "
|
|
32
|
+
"opencode-ai": "1.1.3"
|
|
33
33
|
},
|
|
34
34
|
"peerDependencies": {
|
|
35
|
-
"zod": "
|
|
35
|
+
"zod": "4.3.5"
|
|
36
36
|
},
|
|
37
37
|
"devDependencies": {
|
|
38
38
|
"@eslint/js": "^9.39.2",
|
|
39
39
|
"bun-types": "^1.3.5",
|
|
40
40
|
"eslint": "^9.39.2",
|
|
41
|
-
"globals": "^
|
|
41
|
+
"globals": "^17.0.0",
|
|
42
42
|
"jiti": "^2.6.1",
|
|
43
43
|
"prettier": "^3.7.4",
|
|
44
44
|
"typescript": "^5.0.0",
|
|
45
|
-
"typescript-eslint": "^8.
|
|
45
|
+
"typescript-eslint": "^8.52.0",
|
|
46
46
|
"vitest": "^4.0.0"
|
|
47
47
|
},
|
|
48
48
|
"scripts": {
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
{
|
|
2
|
+
"sessionId": "20251231_092022",
|
|
3
|
+
"startedAt": "2025-12-31T09:20:22.199Z",
|
|
4
|
+
"phase": "init",
|
|
5
|
+
"steps": [
|
|
6
|
+
{
|
|
7
|
+
"stepName": "MoodAnalyzer",
|
|
8
|
+
"timestamp": "2025-12-31T09:20:25.808Z",
|
|
9
|
+
"result": {
|
|
10
|
+
"data": {
|
|
11
|
+
"mood": "happy",
|
|
12
|
+
"keywords": [
|
|
13
|
+
"happy",
|
|
14
|
+
"so",
|
|
15
|
+
"today"
|
|
16
|
+
]
|
|
17
|
+
},
|
|
18
|
+
"stepName": "MoodAnalyzer",
|
|
19
|
+
"duration": 3608,
|
|
20
|
+
"sessionId": "ses_48c4aa762ffeP1pxFsOURK4cw2",
|
|
21
|
+
"model": {
|
|
22
|
+
"providerID": "github-copilot",
|
|
23
|
+
"modelID": "grok-code-fast-1"
|
|
24
|
+
},
|
|
25
|
+
"attempt": 1
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
"stepName": "ResponseSuggester",
|
|
30
|
+
"timestamp": "2025-12-31T09:20:30.186Z",
|
|
31
|
+
"result": {
|
|
32
|
+
"data": {
|
|
33
|
+
"suggestion": "That's wonderful! What's making you so happy today?"
|
|
34
|
+
},
|
|
35
|
+
"stepName": "ResponseSuggester",
|
|
36
|
+
"duration": 4377,
|
|
37
|
+
"sessionId": "ses_48c4aa762ffeP1pxFsOURK4cw2",
|
|
38
|
+
"model": {
|
|
39
|
+
"providerID": "github-copilot",
|
|
40
|
+
"modelID": "grok-code-fast-1"
|
|
41
|
+
},
|
|
42
|
+
"attempt": 1
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
],
|
|
46
|
+
"subPipelines": [],
|
|
47
|
+
"inputText": "I am so happy today!",
|
|
48
|
+
"opencodeSessionId": "ses_48c4aa762ffeP1pxFsOURK4cw2",
|
|
49
|
+
"mood": "happy",
|
|
50
|
+
"keywords": [
|
|
51
|
+
"happy",
|
|
52
|
+
"so",
|
|
53
|
+
"today"
|
|
54
|
+
]
|
|
55
|
+
}
|
package/src/agent.ts
CHANGED
|
@@ -4,198 +4,187 @@
|
|
|
4
4
|
* Wraps the OpenCode CLI for running LLM agents with session management.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import { spawn, execSync } from
|
|
8
|
-
import { mkdir } from
|
|
9
|
-
import { PROJECT_ROOT, TMP_DIR } from
|
|
10
|
-
import type { RunAgentOptions, RunAgentResult } from
|
|
7
|
+
import { spawn, execSync } from "child_process";
|
|
8
|
+
import { mkdir } from "fs/promises";
|
|
9
|
+
import { PROJECT_ROOT, TMP_DIR } from "./paths.js";
|
|
10
|
+
import type { RunAgentOptions, RunAgentResult } from "./types.js";
|
|
11
11
|
|
|
12
12
|
/** Check if opencode is available in system PATH */
|
|
13
13
|
function hasSystemOpencode(): boolean {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
14
|
+
try {
|
|
15
|
+
execSync("which opencode", { stdio: "ignore" });
|
|
16
|
+
return true;
|
|
17
|
+
} catch {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
/** Get command and args to invoke opencode */
|
|
23
23
|
function getOpencodeCommand(args: string[]): { cmd: string; args: string[] } {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
24
|
+
if (hasSystemOpencode()) {
|
|
25
|
+
return { cmd: "opencode", args };
|
|
26
|
+
}
|
|
27
|
+
// Fallback to bunx with ocpipe package (which has opencode-ai as dependency)
|
|
28
|
+
return { cmd: "bunx", args: ["-p", "ocpipe", "opencode", ...args] };
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
/** runAgent executes an OpenCode agent with a prompt, streaming output in real-time. */
|
|
32
|
-
export async function runAgent(
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
resolve({
|
|
129
|
-
text: response,
|
|
130
|
-
sessionId: newSessionId,
|
|
131
|
-
})
|
|
132
|
-
})
|
|
133
|
-
|
|
134
|
-
proc.on('error', (err) => {
|
|
135
|
-
clearTimeout(timeout)
|
|
136
|
-
reject(err)
|
|
137
|
-
})
|
|
138
|
-
})
|
|
32
|
+
export async function runAgent(options: RunAgentOptions): Promise<RunAgentResult> {
|
|
33
|
+
const { prompt, agent, model, sessionId, timeoutSec = 300 } = options;
|
|
34
|
+
|
|
35
|
+
const modelStr = `${model.providerID}/${model.modelID}`;
|
|
36
|
+
const sessionInfo = sessionId ? `[session:${sessionId}]` : "[new session]";
|
|
37
|
+
const promptPreview = prompt.slice(0, 50).replace(/\n/g, " ");
|
|
38
|
+
|
|
39
|
+
console.error(`\n>>> OpenCode [${agent}] [${modelStr}] ${sessionInfo}: ${promptPreview}...`);
|
|
40
|
+
|
|
41
|
+
const args = ["run", "--format", "default", "--agent", agent, "--model", modelStr];
|
|
42
|
+
|
|
43
|
+
if (sessionId) {
|
|
44
|
+
args.push("--session", sessionId);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return new Promise((resolve, reject) => {
|
|
48
|
+
const opencodeCmd = getOpencodeCommand(args);
|
|
49
|
+
const proc = spawn(opencodeCmd.cmd, opencodeCmd.args, {
|
|
50
|
+
cwd: PROJECT_ROOT,
|
|
51
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
let newSessionId = sessionId || "";
|
|
55
|
+
const stdoutChunks: string[] = [];
|
|
56
|
+
|
|
57
|
+
// Stream stderr in real-time (OpenCode progress output)
|
|
58
|
+
proc.stderr.on("data", (data: Buffer) => {
|
|
59
|
+
const text = data.toString();
|
|
60
|
+
|
|
61
|
+
// Parse session ID from output
|
|
62
|
+
for (const line of text.split("\n")) {
|
|
63
|
+
if (line.startsWith("[session:")) {
|
|
64
|
+
newSessionId = line.trim().slice(9, -1);
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
// Filter noise
|
|
68
|
+
if (line.includes("baseline-browser-mapping")) continue;
|
|
69
|
+
if (line.startsWith("$ bun run")) continue;
|
|
70
|
+
if (line.trim()) {
|
|
71
|
+
process.stderr.write(line + "\n");
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// Collect stdout
|
|
77
|
+
proc.stdout.on("data", (data: Buffer) => {
|
|
78
|
+
const text = data.toString();
|
|
79
|
+
stdoutChunks.push(text);
|
|
80
|
+
process.stderr.write(text);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// Send prompt to stdin
|
|
84
|
+
proc.stdin.write(prompt);
|
|
85
|
+
proc.stdin.end();
|
|
86
|
+
|
|
87
|
+
// Timeout handling (0 = no timeout)
|
|
88
|
+
const timeout =
|
|
89
|
+
timeoutSec > 0
|
|
90
|
+
? setTimeout(() => {
|
|
91
|
+
proc.kill();
|
|
92
|
+
reject(new Error(`Timeout after ${timeoutSec}s`));
|
|
93
|
+
}, timeoutSec * 1000)
|
|
94
|
+
: null;
|
|
95
|
+
|
|
96
|
+
proc.on("close", async (code) => {
|
|
97
|
+
if (timeout) clearTimeout(timeout);
|
|
98
|
+
|
|
99
|
+
if (code !== 0) {
|
|
100
|
+
reject(new Error(`OpenCode exited with code ${code}`));
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Export session to get structured response
|
|
105
|
+
let response = stdoutChunks.join("").trim();
|
|
106
|
+
|
|
107
|
+
if (newSessionId) {
|
|
108
|
+
const exported = await exportSession(newSessionId);
|
|
109
|
+
if (exported) {
|
|
110
|
+
response = exported;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const sessionStr = newSessionId || "none";
|
|
115
|
+
console.error(`<<< OpenCode done (${response.length} chars) [session:${sessionStr}]`);
|
|
116
|
+
|
|
117
|
+
resolve({
|
|
118
|
+
text: response,
|
|
119
|
+
sessionId: newSessionId,
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
proc.on("error", (err) => {
|
|
124
|
+
if (timeout) clearTimeout(timeout);
|
|
125
|
+
reject(err);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
139
128
|
}
|
|
140
129
|
|
|
141
130
|
/** exportSession exports a session and extracts assistant text responses. */
|
|
142
131
|
async function exportSession(sessionId: string): Promise<string | null> {
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
132
|
+
const tmpPath = `${TMP_DIR}/opencode_export_${Date.now()}.json`;
|
|
133
|
+
|
|
134
|
+
try {
|
|
135
|
+
await mkdir(TMP_DIR, { recursive: true });
|
|
136
|
+
const opencodeCmd = getOpencodeCommand([
|
|
137
|
+
"session",
|
|
138
|
+
"export",
|
|
139
|
+
sessionId,
|
|
140
|
+
"--format",
|
|
141
|
+
"json",
|
|
142
|
+
"-o",
|
|
143
|
+
tmpPath,
|
|
144
|
+
]);
|
|
145
|
+
const proc = Bun.spawn([opencodeCmd.cmd, ...opencodeCmd.args], {
|
|
146
|
+
cwd: PROJECT_ROOT,
|
|
147
|
+
stdout: "pipe",
|
|
148
|
+
stderr: "pipe",
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
await proc.exited;
|
|
152
|
+
|
|
153
|
+
const file = Bun.file(tmpPath);
|
|
154
|
+
if (!(await file.exists())) return null;
|
|
155
|
+
|
|
156
|
+
const data = (await file.json()) as {
|
|
157
|
+
messages?: Array<{
|
|
158
|
+
info?: { role?: string };
|
|
159
|
+
parts?: Array<{ type?: string; text?: string }>;
|
|
160
|
+
}>;
|
|
161
|
+
};
|
|
162
|
+
await Bun.write(tmpPath, ""); // Clean up
|
|
163
|
+
|
|
164
|
+
// Extract all assistant text parts
|
|
165
|
+
const messages = data.messages || [];
|
|
166
|
+
const textParts: string[] = [];
|
|
167
|
+
|
|
168
|
+
for (const msg of messages) {
|
|
169
|
+
if (msg.info?.role === "assistant") {
|
|
170
|
+
for (const part of msg.parts || []) {
|
|
171
|
+
if (part.type === "text" && part.text) {
|
|
172
|
+
textParts.push(part.text);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return textParts.length > 0 ? textParts.join("\n") : null;
|
|
179
|
+
} catch {
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
193
182
|
}
|
|
194
183
|
|
|
195
184
|
/** logStep logs a step header for workflow progress. */
|
|
196
|
-
export function logStep(step: number, title: string, detail =
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
185
|
+
export function logStep(step: number, title: string, detail = ""): void {
|
|
186
|
+
const detailStr = detail ? ` (${detail})` : "";
|
|
187
|
+
console.log(`\n${"=".repeat(60)}`);
|
|
188
|
+
console.log(`STEP ${step}: ${title}${detailStr}`);
|
|
189
|
+
console.log("=".repeat(60));
|
|
201
190
|
}
|
package/src/parsing.ts
CHANGED
|
@@ -79,6 +79,7 @@ export function tryParseJson<T>(
|
|
|
79
79
|
ok: false,
|
|
80
80
|
errors: [
|
|
81
81
|
{
|
|
82
|
+
code: 'no_json_found',
|
|
82
83
|
path: '',
|
|
83
84
|
message: 'No JSON found in response',
|
|
84
85
|
expectedType: 'object',
|
|
@@ -96,6 +97,7 @@ export function tryParseJson<T>(
|
|
|
96
97
|
ok: false,
|
|
97
98
|
errors: [
|
|
98
99
|
{
|
|
100
|
+
code: 'json_parse_failed',
|
|
99
101
|
path: '',
|
|
100
102
|
message: `JSON parse failed: ${parseErr.message}`,
|
|
101
103
|
expectedType: 'object',
|
|
@@ -157,6 +159,7 @@ function zodErrorsToFieldErrors(
|
|
|
157
159
|
}
|
|
158
160
|
|
|
159
161
|
errors.push({
|
|
162
|
+
code: 'schema_validation_failed',
|
|
160
163
|
path,
|
|
161
164
|
message: issue.message,
|
|
162
165
|
expectedType,
|
|
@@ -1060,3 +1063,41 @@ export function parseJsonFromResponse<T = Record<string, unknown>>(
|
|
|
1060
1063
|
response,
|
|
1061
1064
|
)
|
|
1062
1065
|
}
|
|
1066
|
+
|
|
1067
|
+
/** buildJsonRepairPrompt creates a prompt asking the model to fix malformed JSON. */
|
|
1068
|
+
export function buildJsonRepairPrompt(
|
|
1069
|
+
malformedJson: string,
|
|
1070
|
+
errorMessage: string,
|
|
1071
|
+
schema: Record<string, FieldConfig>,
|
|
1072
|
+
): string {
|
|
1073
|
+
const lines: string[] = []
|
|
1074
|
+
|
|
1075
|
+
lines.push(
|
|
1076
|
+
'Your previous JSON output has a syntax error and cannot be parsed.',
|
|
1077
|
+
)
|
|
1078
|
+
lines.push('')
|
|
1079
|
+
lines.push(`Error: ${errorMessage}`)
|
|
1080
|
+
lines.push('')
|
|
1081
|
+
lines.push('The malformed JSON (may be truncated):')
|
|
1082
|
+
lines.push('```')
|
|
1083
|
+
lines.push(malformedJson.slice(0, 2000))
|
|
1084
|
+
if (malformedJson.length > 2000) {
|
|
1085
|
+
lines.push('... (truncated)')
|
|
1086
|
+
}
|
|
1087
|
+
lines.push('```')
|
|
1088
|
+
lines.push('')
|
|
1089
|
+
lines.push('Please output the COMPLETE, VALID JSON that matches this schema:')
|
|
1090
|
+
lines.push('```json')
|
|
1091
|
+
|
|
1092
|
+
// Build a simple schema description
|
|
1093
|
+
const schemaDesc: Record<string, string> = {}
|
|
1094
|
+
for (const [name, config] of Object.entries(schema)) {
|
|
1095
|
+
schemaDesc[name] = config.desc ?? zodTypeToString(config.type)
|
|
1096
|
+
}
|
|
1097
|
+
lines.push(JSON.stringify(schemaDesc, null, 2))
|
|
1098
|
+
lines.push('```')
|
|
1099
|
+
lines.push('')
|
|
1100
|
+
lines.push('Respond with ONLY the corrected JSON object, no explanation.')
|
|
1101
|
+
|
|
1102
|
+
return lines.join('\n')
|
|
1103
|
+
}
|
package/src/predict.ts
CHANGED
|
@@ -20,6 +20,8 @@ import type {
|
|
|
20
20
|
import { runAgent } from './agent.js'
|
|
21
21
|
import {
|
|
22
22
|
tryParseResponse,
|
|
23
|
+
extractJsonString,
|
|
24
|
+
buildJsonRepairPrompt,
|
|
23
25
|
// jq-style patches
|
|
24
26
|
buildPatchPrompt,
|
|
25
27
|
buildBatchPatchPrompt,
|
|
@@ -78,7 +80,7 @@ export class Predict<S extends AnySignature> {
|
|
|
78
80
|
// Update context with new session ID for continuity
|
|
79
81
|
ctx.sessionId = agentResult.sessionId
|
|
80
82
|
|
|
81
|
-
|
|
83
|
+
let parseResult = tryParseResponse<InferOutputs<S>>(
|
|
82
84
|
agentResult.text,
|
|
83
85
|
this.sig.outputs,
|
|
84
86
|
)
|
|
@@ -94,7 +96,42 @@ export class Predict<S extends AnySignature> {
|
|
|
94
96
|
}
|
|
95
97
|
}
|
|
96
98
|
|
|
97
|
-
//
|
|
99
|
+
// Check if this is a JSON parse error (malformed JSON, not schema validation)
|
|
100
|
+
const isJsonParseError =
|
|
101
|
+
parseResult.errors?.some(
|
|
102
|
+
(e) => e.code === 'json_parse_failed' || e.code === 'no_json_found',
|
|
103
|
+
) ?? false
|
|
104
|
+
|
|
105
|
+
// Attempt JSON repair if enabled and we have a parse error
|
|
106
|
+
if (this.config.correction !== false && isJsonParseError) {
|
|
107
|
+
const rawJson = extractJsonString(agentResult.text)
|
|
108
|
+
const repairedResult = await this.repairJson(
|
|
109
|
+
rawJson ?? agentResult.text,
|
|
110
|
+
parseResult.errors?.[0]?.message ?? 'JSON parse failed',
|
|
111
|
+
ctx,
|
|
112
|
+
agentResult.sessionId,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
if (repairedResult) {
|
|
116
|
+
// Re-parse the repaired response
|
|
117
|
+
parseResult = tryParseResponse<InferOutputs<S>>(
|
|
118
|
+
repairedResult,
|
|
119
|
+
this.sig.outputs,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
if (parseResult.ok && parseResult.data) {
|
|
123
|
+
return {
|
|
124
|
+
data: parseResult.data,
|
|
125
|
+
raw: agentResult.text,
|
|
126
|
+
sessionId: agentResult.sessionId,
|
|
127
|
+
duration: Date.now() - startTime,
|
|
128
|
+
model: this.config.model ?? ctx.defaultModel,
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Parsing failed - attempt field correction if enabled and we have parsed JSON
|
|
98
135
|
if (
|
|
99
136
|
this.config.correction !== false &&
|
|
100
137
|
parseResult.errors &&
|
|
@@ -135,6 +172,62 @@ export class Predict<S extends AnySignature> {
|
|
|
135
172
|
)
|
|
136
173
|
}
|
|
137
174
|
|
|
175
|
+
/** repairJson asks the model to fix malformed JSON. */
|
|
176
|
+
private async repairJson(
|
|
177
|
+
malformedJson: string,
|
|
178
|
+
errorMessage: string,
|
|
179
|
+
ctx: ExecutionContext,
|
|
180
|
+
sessionId: string,
|
|
181
|
+
): Promise<string | null> {
|
|
182
|
+
const correctionConfig =
|
|
183
|
+
typeof this.config.correction === 'object' ? this.config.correction : {}
|
|
184
|
+
const maxRounds = correctionConfig.maxRounds ?? 3
|
|
185
|
+
const correctionModel = correctionConfig.model
|
|
186
|
+
|
|
187
|
+
for (let round = 1; round <= maxRounds; round++) {
|
|
188
|
+
console.error(
|
|
189
|
+
`\n>>> JSON repair round ${round}/${maxRounds}: fixing malformed JSON...`,
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
const repairPrompt = buildJsonRepairPrompt(
|
|
193
|
+
malformedJson,
|
|
194
|
+
errorMessage,
|
|
195
|
+
this.sig.outputs,
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
// Use same session so the model has context of what it was trying to output
|
|
199
|
+
const repairResult = await runAgent({
|
|
200
|
+
prompt: repairPrompt,
|
|
201
|
+
model: correctionModel ?? ctx.defaultModel,
|
|
202
|
+
sessionId: correctionModel ? undefined : sessionId,
|
|
203
|
+
agent: ctx.defaultAgent,
|
|
204
|
+
timeoutSec: 60,
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
// Try to parse the repaired JSON
|
|
208
|
+
const repairedJson = extractJsonString(repairResult.text)
|
|
209
|
+
if (repairedJson) {
|
|
210
|
+
try {
|
|
211
|
+
JSON.parse(repairedJson)
|
|
212
|
+
console.error(` JSON repair successful after ${round} round(s)!`)
|
|
213
|
+
return repairedJson
|
|
214
|
+
} catch (e) {
|
|
215
|
+
const parseErr = e as SyntaxError
|
|
216
|
+
console.error(` Repair attempt ${round} failed: ${parseErr.message}`)
|
|
217
|
+
malformedJson = repairedJson
|
|
218
|
+
errorMessage = parseErr.message
|
|
219
|
+
}
|
|
220
|
+
} else {
|
|
221
|
+
console.error(
|
|
222
|
+
` Repair attempt ${round} failed: no JSON found in response`,
|
|
223
|
+
)
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
console.error(` JSON repair failed after ${maxRounds} rounds`)
|
|
228
|
+
return null
|
|
229
|
+
}
|
|
230
|
+
|
|
138
231
|
/** correctFields attempts to fix field errors using same-session patches with retries. */
|
|
139
232
|
private async correctFields(
|
|
140
233
|
json: Record<string, unknown>,
|
package/src/types.ts
CHANGED
|
@@ -173,7 +173,12 @@ export type CorrectionMethod = 'json-patch' | 'jq'
|
|
|
173
173
|
export interface CorrectionConfig {
|
|
174
174
|
/** Correction method to use (default: 'json-patch'). */
|
|
175
175
|
method?: CorrectionMethod
|
|
176
|
-
/**
|
|
176
|
+
/**
|
|
177
|
+
* Use a different model for corrections.
|
|
178
|
+
* When specified, the correction runs in a new session (no context from the original model).
|
|
179
|
+
* When not specified, corrections reuse the original session so the model has context
|
|
180
|
+
* of what it was trying to output.
|
|
181
|
+
*/
|
|
177
182
|
model?: ModelConfig
|
|
178
183
|
/** Maximum number of fields to attempt correcting per round (default: 5). */
|
|
179
184
|
maxFields?: number
|
|
@@ -181,8 +186,16 @@ export interface CorrectionConfig {
|
|
|
181
186
|
maxRounds?: number
|
|
182
187
|
}
|
|
183
188
|
|
|
189
|
+
/** Error codes for field errors, enabling robust error type detection. */
|
|
190
|
+
export type FieldErrorCode =
|
|
191
|
+
| 'json_parse_failed'
|
|
192
|
+
| 'no_json_found'
|
|
193
|
+
| 'schema_validation_failed'
|
|
194
|
+
|
|
184
195
|
/** A field-level error from schema validation. */
|
|
185
196
|
export interface FieldError {
|
|
197
|
+
/** Error code for programmatic detection. */
|
|
198
|
+
code: FieldErrorCode
|
|
186
199
|
/** The field path that failed (e.g., "issues.0.issue_type"). */
|
|
187
200
|
path: string
|
|
188
201
|
/** Human-readable error message. */
|