@desplega.ai/agent-swarm 1.76.1 → 1.76.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/openapi.json +11 -4
- package/package.json +1 -1
- package/src/be/memory/raters/llm.ts +26 -0
- package/src/hooks/hook.ts +174 -147
- package/src/http/config.ts +15 -3
- package/src/http/core.ts +108 -0
- package/src/http/status.ts +8 -0
- package/src/providers/claude-adapter.ts +9 -1
- package/src/providers/codex-adapter.ts +232 -2
- package/src/providers/codex-oauth/storage.ts +21 -0
- package/src/providers/pi-mono-extension.ts +114 -77
- package/src/telemetry.ts +28 -0
- package/src/tests/claude-stop-hook.test.ts +432 -0
- package/src/tests/codex-adapter.test.ts +436 -1
- package/src/tests/internal-ai/complete-structured.test.ts +276 -0
- package/src/tests/internal-ai/credentials.test.ts +264 -0
- package/src/tests/internal-ai/schema-parity.test.ts +103 -0
- package/src/tests/internal-ai/summarize-session.test.ts +105 -0
- package/src/tests/opencode-plugin.test.ts +496 -0
- package/src/tests/pi-mono-extension.test.ts +347 -0
- package/src/tests/reload-config.test.ts +151 -3
- package/src/tests/status.test.ts +4 -0
- package/src/tests/telemetry-init.test.ts +137 -1
- package/src/tests/template-recommendations.test.ts +1 -0
- package/src/utils/internal-ai/complete-structured.ts +296 -0
- package/src/utils/internal-ai/credentials.ts +175 -0
- package/src/utils/internal-ai/index.ts +31 -0
- package/src/utils/internal-ai/models.ts +46 -0
- package/src/utils/internal-ai/summarize-session.ts +101 -0
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* General-purpose structured-output LLM wrapper.
|
|
3
|
+
*
|
|
4
|
+
* Plan: thoughts/taras/plans/2026-05-10-fix-session-summarization-workers.md
|
|
5
|
+
* → Phase 0 § "complete-structured.ts"
|
|
6
|
+
*
|
|
7
|
+
* Context-agnostic: callable from both worker subprocesses and the API
|
|
8
|
+
* server. Resolves a credential per the precedence in `./credentials.ts`,
|
|
9
|
+
* then either:
|
|
10
|
+
* - Calls pi-ai's `complete()` with a single typebox-defined tool and
|
|
11
|
+
* extracts the tool-call payload (provider/anthropic/openai/openai-codex
|
|
12
|
+
* paths), OR
|
|
13
|
+
* - Shells out to `claude -p` (CLAUDE_CODE_OAUTH_TOKEN fallback path).
|
|
14
|
+
*
|
|
15
|
+
* Worker-safe: uses fetch() only, no bun:sqlite import.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { ToolCall } from "@mariozechner/pi-ai";
|
|
19
|
+
import { complete, getModel } from "@mariozechner/pi-ai";
|
|
20
|
+
import type { TSchema } from "typebox";
|
|
21
|
+
import { z } from "zod";
|
|
22
|
+
import { type ResolvedCredential, resolveCredential } from "./credentials.js";
|
|
23
|
+
import { parseModelStr } from "./models.js";
|
|
24
|
+
|
|
25
|
+
export interface CompleteStructuredOptions<TZod extends z.ZodTypeAny> {
|
|
26
|
+
/** Zod schema used for output validation (final source of truth). */
|
|
27
|
+
zodSchema: TZod;
|
|
28
|
+
/** Typebox schema used as pi-ai `Tool.parameters` (provider-side validation). */
|
|
29
|
+
toolSchema: TSchema;
|
|
30
|
+
toolName: string;
|
|
31
|
+
toolDescription: string;
|
|
32
|
+
systemPrompt: string;
|
|
33
|
+
userPrompt: string;
|
|
34
|
+
/**
|
|
35
|
+
* Optional context for codex-OAuth lookup. When omitted, only env vars are tried.
|
|
36
|
+
* - Workers: pass `config.apiUrl` / `config.apiKey`.
|
|
37
|
+
* - API server: pass `MCP_BASE_URL` / `API_KEY` (loopback).
|
|
38
|
+
* - Skip entirely to disable codex OAuth probing.
|
|
39
|
+
*/
|
|
40
|
+
apiUrl?: string;
|
|
41
|
+
apiKey?: string;
|
|
42
|
+
/** Default: 3. */
|
|
43
|
+
retries?: number;
|
|
44
|
+
signal?: AbortSignal;
|
|
45
|
+
/** Optional diagnostic tag (e.g. `"session-summary:pi"`). */
|
|
46
|
+
callerTag?: string;
|
|
47
|
+
// Test injection points:
|
|
48
|
+
_resolveCredential?: typeof resolveCredential;
|
|
49
|
+
_complete?: typeof complete;
|
|
50
|
+
_spawnClaudeCli?: (
|
|
51
|
+
prompt: string,
|
|
52
|
+
model: string,
|
|
53
|
+
signal?: AbortSignal,
|
|
54
|
+
jsonSchema?: object,
|
|
55
|
+
) => Promise<string>;
|
|
56
|
+
/**
|
|
57
|
+
* Bypass `resolveCredential` entirely — opencode auth path (and tests)
|
|
58
|
+
* pass an already-resolved credential.
|
|
59
|
+
*/
|
|
60
|
+
_credentialOverride?: ResolvedCredential;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Default 30s timeout for the `claude -p` shellout — matches the existing
|
|
65
|
+
* pattern in `src/providers/pi-mono-extension.ts:328-351` (the call site
|
|
66
|
+
* being retired in Phase 1).
|
|
67
|
+
*/
|
|
68
|
+
const CLAUDE_CLI_TIMEOUT_MS = 30_000;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Tolerant JSON extractor for `claude -p` `result` strings. Claude sometimes
|
|
72
|
+
* wraps JSON in ```json … ``` fences despite a "no code fences" prompt; this
|
|
73
|
+
* peels off the fence so `JSON.parse` can handle it.
|
|
74
|
+
*/
|
|
75
|
+
function stripJsonFences(raw: string): string {
|
|
76
|
+
const trimmed = raw.trim();
|
|
77
|
+
const fenced = trimmed.match(/^```(?:json)?\s*\n?([\s\S]*?)\n?```$/);
|
|
78
|
+
return fenced?.[1] ? fenced[1].trim() : trimmed;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function defaultSpawnClaudeCli(
|
|
82
|
+
prompt: string,
|
|
83
|
+
model: string,
|
|
84
|
+
signal?: AbortSignal,
|
|
85
|
+
jsonSchema?: object,
|
|
86
|
+
): Promise<string> {
|
|
87
|
+
const cmd = [
|
|
88
|
+
process.env.CLAUDE_BINARY ?? "claude",
|
|
89
|
+
"-p",
|
|
90
|
+
"--model",
|
|
91
|
+
model,
|
|
92
|
+
"--output-format",
|
|
93
|
+
"json",
|
|
94
|
+
];
|
|
95
|
+
if (jsonSchema) {
|
|
96
|
+
cmd.push("--json-schema", JSON.stringify(jsonSchema));
|
|
97
|
+
}
|
|
98
|
+
// The hook subprocess receives an empty CLAUDE_CODE_OAUTH_TOKEN (claude
|
|
99
|
+
// CLI strips it from hooks). Restore it from the mirror set by
|
|
100
|
+
// claude-adapter.ts so the inner `claude -p` invocation authenticates.
|
|
101
|
+
const env: Record<string, string> = { ...(process.env as Record<string, string>) };
|
|
102
|
+
if (!env.CLAUDE_CODE_OAUTH_TOKEN && env.AGENT_SWARM_CLAUDE_OAUTH_TOKEN) {
|
|
103
|
+
env.CLAUDE_CODE_OAUTH_TOKEN = env.AGENT_SWARM_CLAUDE_OAUTH_TOKEN;
|
|
104
|
+
}
|
|
105
|
+
const proc = Bun.spawn({
|
|
106
|
+
cmd,
|
|
107
|
+
env,
|
|
108
|
+
stdin: "pipe",
|
|
109
|
+
stdout: "pipe",
|
|
110
|
+
stderr: "pipe",
|
|
111
|
+
});
|
|
112
|
+
const timeout = setTimeout(() => {
|
|
113
|
+
try {
|
|
114
|
+
proc.kill();
|
|
115
|
+
} catch {
|
|
116
|
+
// ignore — process may have already exited
|
|
117
|
+
}
|
|
118
|
+
}, CLAUDE_CLI_TIMEOUT_MS);
|
|
119
|
+
const abortHandler = () => {
|
|
120
|
+
try {
|
|
121
|
+
proc.kill();
|
|
122
|
+
} catch {
|
|
123
|
+
// ignore
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
signal?.addEventListener("abort", abortHandler, { once: true });
|
|
127
|
+
try {
|
|
128
|
+
proc.stdin.write(prompt);
|
|
129
|
+
await proc.stdin.end();
|
|
130
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
131
|
+
new Response(proc.stdout).text(),
|
|
132
|
+
new Response(proc.stderr).text(),
|
|
133
|
+
proc.exited,
|
|
134
|
+
]);
|
|
135
|
+
if (exitCode !== 0) {
|
|
136
|
+
console.error(`internal-ai: claude -p exited ${exitCode}; stderr=${stderr.slice(0, 500)}`);
|
|
137
|
+
}
|
|
138
|
+
// claude -p --output-format json envelope shape:
|
|
139
|
+
// { ..., result: "<text>", structured_output?: <validated-object> }
|
|
140
|
+
// When --json-schema is passed, prefer `structured_output` (validated
|
|
141
|
+
// by claude server-side). When it's absent, fall back to `result` — the
|
|
142
|
+
// caller has also embedded the schema in the prompt so `result` should
|
|
143
|
+
// be valid JSON; if it isn't, the caller's JSON.parse retry surfaces it.
|
|
144
|
+
try {
|
|
145
|
+
const envelope = JSON.parse(stdout) as { result?: string; structured_output?: unknown };
|
|
146
|
+
if (jsonSchema && envelope.structured_output !== undefined) {
|
|
147
|
+
return JSON.stringify(envelope.structured_output);
|
|
148
|
+
}
|
|
149
|
+
return envelope.result ?? stdout;
|
|
150
|
+
} catch {
|
|
151
|
+
return stdout;
|
|
152
|
+
}
|
|
153
|
+
} finally {
|
|
154
|
+
clearTimeout(timeout);
|
|
155
|
+
signal?.removeEventListener("abort", abortHandler);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Run a structured-output completion. Returns the parsed object on success,
|
|
161
|
+
* `null` on auth-missing or exhausted retries (errors logged, never thrown).
|
|
162
|
+
*/
|
|
163
|
+
export async function completeStructured<TZod extends z.ZodTypeAny>(
|
|
164
|
+
opts: CompleteStructuredOptions<TZod>,
|
|
165
|
+
): Promise<z.infer<TZod> | null> {
|
|
166
|
+
const retries = opts.retries ?? 3;
|
|
167
|
+
const callerTag = opts.callerTag ?? "<unset>";
|
|
168
|
+
|
|
169
|
+
// 1. Resolve credential (or use override).
|
|
170
|
+
let cred: ResolvedCredential | null;
|
|
171
|
+
if (opts._credentialOverride) {
|
|
172
|
+
cred = opts._credentialOverride;
|
|
173
|
+
} else {
|
|
174
|
+
const resolver = opts._resolveCredential ?? resolveCredential;
|
|
175
|
+
cred = await resolver({
|
|
176
|
+
env: process.env,
|
|
177
|
+
apiUrl: opts.apiUrl,
|
|
178
|
+
apiKey: opts.apiKey,
|
|
179
|
+
callerTag: opts.callerTag,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
if (!cred) {
|
|
183
|
+
// No auth — graceful no-op. Don't log noisily; callers may invoke this
|
|
184
|
+
// routinely in environments without LLM credentials.
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Always-on debug log — relied on by Manual E2E Scenario 5 + Phase 4 QA.
|
|
189
|
+
console.log(`internal-ai: kind=${cred.kind} callerTag=${callerTag}`);
|
|
190
|
+
|
|
191
|
+
// 2. Claude-CLI fallback path.
|
|
192
|
+
if (cred.kind === "claude-cli") {
|
|
193
|
+
const spawn = opts._spawnClaudeCli ?? defaultSpawnClaudeCli;
|
|
194
|
+
// Belt-and-suspenders: pass the schema both as `--json-schema` (sets
|
|
195
|
+
// `envelope.structured_output` when the model complies) AND inline in
|
|
196
|
+
// the prompt (forces JSON-only `envelope.result` when it doesn't).
|
|
197
|
+
// The CLI flag alone is unreliable — claude sometimes asks "where's
|
|
198
|
+
// the schema?" if the prompt doesn't reference one.
|
|
199
|
+
const jsonSchema = z.toJSONSchema(opts.zodSchema) as object;
|
|
200
|
+
const schemaStr = JSON.stringify(jsonSchema);
|
|
201
|
+
const claudeUserPrompt = `${opts.userPrompt}\n\nRespond with ONLY a JSON object (no prose, no code fences) matching this schema:\n${schemaStr}`;
|
|
202
|
+
let lastErr: unknown = null;
|
|
203
|
+
for (let attempt = 0; attempt < retries; attempt++) {
|
|
204
|
+
try {
|
|
205
|
+
const raw = await spawn(
|
|
206
|
+
`${opts.systemPrompt}\n\n${claudeUserPrompt}`,
|
|
207
|
+
cred.modelDefault,
|
|
208
|
+
opts.signal,
|
|
209
|
+
jsonSchema,
|
|
210
|
+
);
|
|
211
|
+
let parsedJson: unknown;
|
|
212
|
+
try {
|
|
213
|
+
parsedJson = JSON.parse(stripJsonFences(raw));
|
|
214
|
+
} catch (err) {
|
|
215
|
+
lastErr = err;
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
const validated = opts.zodSchema.safeParse(parsedJson);
|
|
219
|
+
if (validated.success) {
|
|
220
|
+
return validated.data;
|
|
221
|
+
}
|
|
222
|
+
lastErr = validated.error;
|
|
223
|
+
} catch (err) {
|
|
224
|
+
lastErr = err;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
console.error(
|
|
228
|
+
`internal-ai: structured output failed after ${retries} retries (callerTag=${callerTag} kind=${cred.kind})`,
|
|
229
|
+
lastErr,
|
|
230
|
+
);
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// 3. pi-ai path (openrouter / anthropic / openai / openai-codex).
|
|
235
|
+
const [provider, modelId] = parseModelStr(cred.modelDefault);
|
|
236
|
+
let model: ReturnType<typeof getModel>;
|
|
237
|
+
try {
|
|
238
|
+
// The typed overload is too restrictive for our dynamic-string case; the
|
|
239
|
+
// runtime tolerates any registered (provider, id) pair.
|
|
240
|
+
model = getModel(provider as Parameters<typeof getModel>[0], modelId as never);
|
|
241
|
+
} catch (err) {
|
|
242
|
+
console.error(
|
|
243
|
+
`internal-ai: getModel(${provider}, ${modelId}) threw (callerTag=${callerTag})`,
|
|
244
|
+
err,
|
|
245
|
+
);
|
|
246
|
+
return null;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const completeFn = opts._complete ?? complete;
|
|
250
|
+
let userPrompt = opts.userPrompt;
|
|
251
|
+
let lastErr: unknown = null;
|
|
252
|
+
for (let attempt = 0; attempt < retries; attempt++) {
|
|
253
|
+
try {
|
|
254
|
+
const msg = await completeFn(
|
|
255
|
+
model,
|
|
256
|
+
{
|
|
257
|
+
systemPrompt: opts.systemPrompt,
|
|
258
|
+
messages: [{ role: "user", content: userPrompt, timestamp: Date.now() }],
|
|
259
|
+
tools: [
|
|
260
|
+
{
|
|
261
|
+
name: opts.toolName,
|
|
262
|
+
description: opts.toolDescription,
|
|
263
|
+
parameters: opts.toolSchema,
|
|
264
|
+
},
|
|
265
|
+
],
|
|
266
|
+
},
|
|
267
|
+
// pi-ai's ProviderStreamOptions type only allows known providers;
|
|
268
|
+
// we pass the validated apiKey through verbatim.
|
|
269
|
+
{ apiKey: cred.apiKey, signal: opts.signal } as Parameters<typeof completeFn>[2],
|
|
270
|
+
);
|
|
271
|
+
|
|
272
|
+
const toolCall = msg.content.find(
|
|
273
|
+
(c): c is ToolCall => c.type === "toolCall" && c.name === opts.toolName,
|
|
274
|
+
);
|
|
275
|
+
if (!toolCall) {
|
|
276
|
+
userPrompt = `${userPrompt}\n\nYou did not call the ${opts.toolName} tool. You MUST call it with the requested arguments.`;
|
|
277
|
+
lastErr = new Error("no tool call in response");
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const validated = opts.zodSchema.safeParse(toolCall.arguments);
|
|
282
|
+
if (validated.success) {
|
|
283
|
+
return validated.data;
|
|
284
|
+
}
|
|
285
|
+
userPrompt = `${userPrompt}\n\nThe ${opts.toolName} arguments did not validate: ${validated.error.message}. Please retry with correct arguments.`;
|
|
286
|
+
lastErr = validated.error;
|
|
287
|
+
} catch (err) {
|
|
288
|
+
lastErr = err;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
console.error(
|
|
292
|
+
`internal-ai: structured output failed after ${retries} retries (callerTag=${callerTag} kind=${cred.kind})`,
|
|
293
|
+
lastErr,
|
|
294
|
+
);
|
|
295
|
+
return null;
|
|
296
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Credential resolver for the internal-ai abstraction.
|
|
3
|
+
*
|
|
4
|
+
* Plan: thoughts/taras/plans/2026-05-10-fix-session-summarization-workers.md
|
|
5
|
+
* → Phase 0 § "credentials.ts"
|
|
6
|
+
*
|
|
7
|
+
* Context-agnostic: tries env vars first, then optionally probes codex OAuth
|
|
8
|
+
* via HTTP if `apiUrl + apiKey` are provided. Workers pass through
|
|
9
|
+
* `config.apiUrl` / `config.apiKey`; API-server callers pass `MCP_BASE_URL` /
|
|
10
|
+
* `API_KEY` (loopback). Callers that don't want codex-OAuth probing omit both.
|
|
11
|
+
*
|
|
12
|
+
* NO HARNESS CHECK — this resolver is harness-agnostic. The codex-OAuth probe
|
|
13
|
+
* costs one localhost HTTP call, which is fine to attempt on any worker.
|
|
14
|
+
*
|
|
15
|
+
* Worker-safe: uses fetch() only, no bun:sqlite import.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { OAuthCredentials } from "@mariozechner/pi-ai";
|
|
19
|
+
import { getEnvApiKey } from "@mariozechner/pi-ai";
|
|
20
|
+
import { getOAuthApiKey } from "@mariozechner/pi-ai/oauth";
|
|
21
|
+
import { getValidCodexOAuth, persistCodexOAuth } from "../../providers/codex-oauth/storage.js";
|
|
22
|
+
import { type CredentialKind, DEFAULT_MODEL, resolveModelString } from "./models.js";
|
|
23
|
+
|
|
24
|
+
export type ResolvedCredential =
|
|
25
|
+
| {
|
|
26
|
+
kind: "openrouter" | "anthropic" | "openai" | "openai-codex";
|
|
27
|
+
apiKey: string;
|
|
28
|
+
modelDefault: string;
|
|
29
|
+
}
|
|
30
|
+
| {
|
|
31
|
+
kind: "claude-cli";
|
|
32
|
+
modelDefault: string;
|
|
33
|
+
// No apiKey — the `claude` CLI uses CLAUDE_CODE_OAUTH_TOKEN from env directly.
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export interface ResolveCredentialOptions {
|
|
37
|
+
/** Defaulted to `process.env`; injectable for tests. */
|
|
38
|
+
env?: NodeJS.ProcessEnv;
|
|
39
|
+
/** Optional: enables codex-OAuth lookup over HTTP. */
|
|
40
|
+
apiUrl?: string;
|
|
41
|
+
/** Optional: paired with apiUrl. */
|
|
42
|
+
apiKey?: string;
|
|
43
|
+
/** Optional log tag — purely for diagnostics, not load-bearing. */
|
|
44
|
+
callerTag?: string;
|
|
45
|
+
/** Test injection: override the codex OAuth lookup. */
|
|
46
|
+
_getValidCodexOAuth?: typeof getValidCodexOAuth;
|
|
47
|
+
/** Test injection: override pi-ai's OAuth-to-API-key resolution. */
|
|
48
|
+
_getOAuthApiKey?: typeof getOAuthApiKey;
|
|
49
|
+
/** Test injection: override pi-ai's env API key lookup. */
|
|
50
|
+
_getEnvApiKey?: typeof getEnvApiKey;
|
|
51
|
+
/** Test injection: override the persistCodexOAuth call. */
|
|
52
|
+
_persistCodexOAuth?: typeof persistCodexOAuth;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Resolve a credential according to the documented precedence. Returns `null`
|
|
57
|
+
* if no credential could be resolved — callers MUST treat null as a graceful
|
|
58
|
+
* no-op (do NOT throw; structured-output completion is a best-effort path).
|
|
59
|
+
*
|
|
60
|
+
* Precedence (top wins):
|
|
61
|
+
* 1. `env.OPENROUTER_API_KEY` (via pi-ai `getEnvApiKey("openrouter")`)
|
|
62
|
+
* 2. `env.ANTHROPIC_API_KEY` (via pi-ai `getEnvApiKey("anthropic")`)
|
|
63
|
+
* 3. `env.OPENAI_API_KEY` (via pi-ai `getEnvApiKey("openai")`)
|
|
64
|
+
* 4. codex OAuth (only when `apiUrl && apiKey` are provided)
|
|
65
|
+
* 5. `env.CLAUDE_CODE_OAUTH_TOKEN` → claude-cli fallback
|
|
66
|
+
* 6. null
|
|
67
|
+
*/
|
|
68
|
+
export async function resolveCredential(
|
|
69
|
+
opts: ResolveCredentialOptions = {},
|
|
70
|
+
): Promise<ResolvedCredential | null> {
|
|
71
|
+
const env = opts.env ?? process.env;
|
|
72
|
+
const getEnvKey = opts._getEnvApiKey ?? getEnvApiKey;
|
|
73
|
+
const getCodex = opts._getValidCodexOAuth ?? getValidCodexOAuth;
|
|
74
|
+
const getOAuth = opts._getOAuthApiKey ?? getOAuthApiKey;
|
|
75
|
+
const persistCodex = opts._persistCodexOAuth ?? persistCodexOAuth;
|
|
76
|
+
|
|
77
|
+
// 1. OpenRouter.
|
|
78
|
+
const openrouterKey = env.OPENROUTER_API_KEY ?? getEnvKey("openrouter");
|
|
79
|
+
if (openrouterKey) {
|
|
80
|
+
return {
|
|
81
|
+
kind: "openrouter",
|
|
82
|
+
apiKey: openrouterKey,
|
|
83
|
+
modelDefault: resolveModelString("openrouter"),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// 2. Anthropic.
|
|
88
|
+
const anthropicKey = env.ANTHROPIC_API_KEY ?? getEnvKey("anthropic");
|
|
89
|
+
if (anthropicKey) {
|
|
90
|
+
return {
|
|
91
|
+
kind: "anthropic",
|
|
92
|
+
apiKey: anthropicKey,
|
|
93
|
+
modelDefault: resolveModelString("anthropic"),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// 3. OpenAI.
|
|
98
|
+
const openaiKey = env.OPENAI_API_KEY ?? getEnvKey("openai");
|
|
99
|
+
if (openaiKey) {
|
|
100
|
+
return {
|
|
101
|
+
kind: "openai",
|
|
102
|
+
apiKey: openaiKey,
|
|
103
|
+
modelDefault: resolveModelString("openai"),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// 4. Codex OAuth — only if we have apiUrl + apiKey to probe the config store.
|
|
108
|
+
if (opts.apiUrl && opts.apiKey) {
|
|
109
|
+
try {
|
|
110
|
+
const codexCreds = await getCodex(opts.apiUrl, opts.apiKey);
|
|
111
|
+
if (codexCreds) {
|
|
112
|
+
// pi-ai expects a Record<providerID, OAuthCredentials>.
|
|
113
|
+
const credMap: Record<string, OAuthCredentials> = {
|
|
114
|
+
"openai-codex": {
|
|
115
|
+
access: codexCreds.access,
|
|
116
|
+
refresh: codexCreds.refresh,
|
|
117
|
+
expires: codexCreds.expires,
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
const oauthResult = await getOAuth("openai-codex", credMap);
|
|
121
|
+
if (oauthResult) {
|
|
122
|
+
// Persist any rotated refresh token — best-effort, must not block.
|
|
123
|
+
// Wrap defensively here even though the production helper already
|
|
124
|
+
// swallows; tests may inject a throwing hook, and persistence
|
|
125
|
+
// failure must never prevent the current call from succeeding.
|
|
126
|
+
if (oauthResult.newCredentials) {
|
|
127
|
+
const updated = oauthResult.newCredentials;
|
|
128
|
+
try {
|
|
129
|
+
await persistCodex(opts.apiUrl, opts.apiKey, {
|
|
130
|
+
access: String(updated.access),
|
|
131
|
+
refresh: String(updated.refresh),
|
|
132
|
+
expires: Number(updated.expires),
|
|
133
|
+
accountId: codexCreds.accountId,
|
|
134
|
+
});
|
|
135
|
+
} catch (err) {
|
|
136
|
+
console.error(
|
|
137
|
+
`internal-ai: persistCodexOAuth failed (callerTag=${opts.callerTag ?? "<unset>"}):`,
|
|
138
|
+
err,
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return {
|
|
143
|
+
kind: "openai-codex",
|
|
144
|
+
apiKey: oauthResult.apiKey,
|
|
145
|
+
modelDefault: resolveModelString("openai-codex"),
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
} catch (err) {
|
|
150
|
+
console.error(
|
|
151
|
+
`internal-ai: codex OAuth probe failed (callerTag=${opts.callerTag ?? "<unset>"}):`,
|
|
152
|
+
err,
|
|
153
|
+
);
|
|
154
|
+
// Fall through to claude-cli fallback below.
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// 5. CLAUDE_CODE_OAUTH_TOKEN → claude-cli fallback.
|
|
159
|
+
// `AGENT_SWARM_CLAUDE_OAUTH_TOKEN` is the mirror set by claude-adapter.ts
|
|
160
|
+
// before spawning `claude` — the CLI strips `CLAUDE_CODE_OAUTH_TOKEN` from
|
|
161
|
+
// hook subprocesses (security), so the hook reads the mirror instead.
|
|
162
|
+
if (env.AGENT_SWARM_CLAUDE_OAUTH_TOKEN || env.CLAUDE_CODE_OAUTH_TOKEN) {
|
|
163
|
+
return {
|
|
164
|
+
kind: "claude-cli",
|
|
165
|
+
modelDefault: resolveModelString("claude-cli"),
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// 6. No creds.
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Re-export for convenience to keep imports flat.
|
|
174
|
+
export { DEFAULT_MODEL };
|
|
175
|
+
export type { CredentialKind };
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Internal-AI: reusable structured-output LLM abstraction for both worker
|
|
3
|
+
* subprocesses and the API server.
|
|
4
|
+
*
|
|
5
|
+
* Plan: thoughts/taras/plans/2026-05-10-fix-session-summarization-workers.md
|
|
6
|
+
*
|
|
7
|
+
* Worker-safe: uses fetch() only, no bun:sqlite import.
|
|
8
|
+
*
|
|
9
|
+
* Public surface:
|
|
10
|
+
* - `completeStructured<TZod>({...})` — context-agnostic lower layer.
|
|
11
|
+
* - `summarizeSession({...})` — worker-side session-end domain helper.
|
|
12
|
+
* - `resolveCredential({...})` — exposed for opencode-auth and tests.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export {
|
|
16
|
+
type CompleteStructuredOptions,
|
|
17
|
+
completeStructured,
|
|
18
|
+
} from "./complete-structured.js";
|
|
19
|
+
export {
|
|
20
|
+
type CredentialKind,
|
|
21
|
+
DEFAULT_MODEL,
|
|
22
|
+
type ResolveCredentialOptions,
|
|
23
|
+
type ResolvedCredential,
|
|
24
|
+
resolveCredential,
|
|
25
|
+
} from "./credentials.js";
|
|
26
|
+
export { parseModelStr, resolveModelString } from "./models.js";
|
|
27
|
+
export {
|
|
28
|
+
type SummarizeSessionOptions,
|
|
29
|
+
summarizeSession,
|
|
30
|
+
summaryToolSchema,
|
|
31
|
+
} from "./summarize-session.js";
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-credential default model registry for the internal-ai abstraction.
|
|
3
|
+
*
|
|
4
|
+
* Plan: thoughts/taras/plans/2026-05-10-fix-session-summarization-workers.md
|
|
5
|
+
* → Phase 0 § "models.ts"
|
|
6
|
+
*
|
|
7
|
+
* Model defaults are per credential kind, NOT per harness — every credential
|
|
8
|
+
* kind has exactly one default model. Override via `MEMORY_RATER_MODEL` env
|
|
9
|
+
* (kept for backwards-compat with the claude hook).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export type CredentialKind = "openrouter" | "anthropic" | "openai" | "openai-codex" | "claude-cli";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Per-credential default model strings. The "claude-cli" kind uses the
|
|
16
|
+
* shorthand "haiku" because the only consumer is the `claude -p --model haiku`
|
|
17
|
+
* shellout, not pi-ai's `getModel`.
|
|
18
|
+
*/
|
|
19
|
+
export const DEFAULT_MODEL: Record<CredentialKind, string> = {
|
|
20
|
+
openrouter: "openrouter/google/gemini-3-flash-preview",
|
|
21
|
+
anthropic: "anthropic/claude-haiku-4-5",
|
|
22
|
+
openai: "openai/gpt-5.4-mini",
|
|
23
|
+
"openai-codex": "openai-codex/gpt-5.4-mini",
|
|
24
|
+
"claude-cli": "haiku",
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Resolve the effective model string for a credential kind. Honours the
|
|
29
|
+
* `MEMORY_RATER_MODEL` env var so existing claude-hook users keep their
|
|
30
|
+
* override (it pre-dates the per-kind registry).
|
|
31
|
+
*/
|
|
32
|
+
export function resolveModelString(kind: CredentialKind): string {
|
|
33
|
+
return process.env.MEMORY_RATER_MODEL ?? DEFAULT_MODEL[kind];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Split a `provider/model-id` string on the FIRST `/` so that OpenRouter
|
|
38
|
+
* compound IDs like `openrouter/google/gemini-3-flash-preview` parse as
|
|
39
|
+
* `("openrouter", "google/gemini-3-flash-preview")`. Mirrors the existing
|
|
40
|
+
* convention in `src/providers/pi-mono-adapter.ts:161-170`.
|
|
41
|
+
*/
|
|
42
|
+
export function parseModelStr(modelStr: string): [provider: string, modelId: string] {
|
|
43
|
+
const idx = modelStr.indexOf("/");
|
|
44
|
+
if (idx < 0) throw new Error(`invalid model string (no '/'): ${modelStr}`);
|
|
45
|
+
return [modelStr.slice(0, idx), modelStr.slice(idx + 1)];
|
|
46
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Worker-side domain helper for session-end summarization.
|
|
3
|
+
*
|
|
4
|
+
* Plan: thoughts/taras/plans/2026-05-10-fix-session-summarization-workers.md
|
|
5
|
+
* → Phase 0 § "summarize-session.ts"
|
|
6
|
+
*
|
|
7
|
+
* Composes `completeStructured` with `SummaryWithRatingsSchema` and the
|
|
8
|
+
* shared `BASE_SUMMARIZE_PROMPT` extracted from `src/hooks/hook.ts`. API-server
|
|
9
|
+
* callers wanting structured AI completion call `completeStructured` directly
|
|
10
|
+
* with their own schemas — this helper is for session-transcript consumers
|
|
11
|
+
* only.
|
|
12
|
+
*
|
|
13
|
+
* Worker-safe: uses fetch() only via underlying helpers; no bun:sqlite import.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { Type } from "typebox";
|
|
17
|
+
import type { z } from "zod";
|
|
18
|
+
import {
|
|
19
|
+
BASE_SUMMARIZE_PROMPT,
|
|
20
|
+
buildSummaryWithRatingsPrompt,
|
|
21
|
+
type RetrievalRow,
|
|
22
|
+
SummaryWithRatingsSchema,
|
|
23
|
+
} from "../../be/memory/raters/llm.js";
|
|
24
|
+
import { completeStructured } from "./complete-structured.js";
|
|
25
|
+
import type { ResolvedCredential } from "./credentials.js";
|
|
26
|
+
|
|
27
|
+
export interface SummarizeSessionOptions {
|
|
28
|
+
/** Diagnostic tag only — propagated into `callerTag`, not into `completeStructured` directly. */
|
|
29
|
+
harness: "claude" | "pi" | "opencode" | "codex";
|
|
30
|
+
/** Pre-truncated transcript text. */
|
|
31
|
+
transcript: string;
|
|
32
|
+
/** Memory retrievals for the per-memory ratings block; [] when not requested. */
|
|
33
|
+
retrievals: RetrievalRow[];
|
|
34
|
+
taskContext: { sourceTaskId: string; agentId: string; prompt?: string };
|
|
35
|
+
/** Passed through to `completeStructured` for codex-OAuth probing. */
|
|
36
|
+
apiUrl: string;
|
|
37
|
+
apiKey: string;
|
|
38
|
+
signal?: AbortSignal;
|
|
39
|
+
/**
|
|
40
|
+
* Bypass `resolveCredential` entirely — opencode auth path (and tests) pass
|
|
41
|
+
* an already-resolved credential through to `completeStructured`. Phase 2
|
|
42
|
+
* amendment: clean injection point so harnesses with their own credential
|
|
43
|
+
* stores (opencode's `auth.json`) can skip the harness-agnostic resolver.
|
|
44
|
+
*/
|
|
45
|
+
_credentialOverride?: ResolvedCredential;
|
|
46
|
+
/** Test injection. */
|
|
47
|
+
_completeStructured?: typeof completeStructured;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Typebox tool schema mirroring `SummaryWithRatingsSchema`.
|
|
52
|
+
*
|
|
53
|
+
* Kept in lockstep with the zod schema via `src/tests/internal-ai/schema-parity.test.ts`
|
|
54
|
+
* which fuzzes both validators with a fixture set.
|
|
55
|
+
*/
|
|
56
|
+
export const summaryToolSchema = Type.Object({
|
|
57
|
+
summary: Type.String(),
|
|
58
|
+
ratings: Type.Array(
|
|
59
|
+
Type.Object({
|
|
60
|
+
id: Type.String({ minLength: 1 }),
|
|
61
|
+
score: Type.Number({ minimum: 0, maximum: 1 }),
|
|
62
|
+
reasoning: Type.String({ minLength: 1, maxLength: 500 }),
|
|
63
|
+
referencesSource: Type.Optional(Type.String({ minLength: 1, maxLength: 512 })),
|
|
64
|
+
}),
|
|
65
|
+
),
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Returns the structured summary (with optional per-memory ratings), or
|
|
70
|
+
* `null` when:
|
|
71
|
+
* - the transcript is too short (≤ 100 chars), OR
|
|
72
|
+
* - `completeStructured` could not resolve a credential, OR
|
|
73
|
+
* - the LLM repeatedly failed to produce schema-valid output.
|
|
74
|
+
*/
|
|
75
|
+
export async function summarizeSession(
|
|
76
|
+
opts: SummarizeSessionOptions,
|
|
77
|
+
): Promise<z.infer<typeof SummaryWithRatingsSchema> | null> {
|
|
78
|
+
if (opts.transcript.length <= 100) return null;
|
|
79
|
+
|
|
80
|
+
const taskLine = opts.taskContext.prompt ? `\nTask: ${opts.taskContext.prompt}` : "";
|
|
81
|
+
const basePrompt = `${BASE_SUMMARIZE_PROMPT}${taskLine}\n\nTranscript:\n${opts.transcript}`;
|
|
82
|
+
const userPrompt = buildSummaryWithRatingsPrompt(basePrompt, opts.retrievals);
|
|
83
|
+
|
|
84
|
+
const runner = opts._completeStructured ?? completeStructured;
|
|
85
|
+
return await runner({
|
|
86
|
+
zodSchema: SummaryWithRatingsSchema,
|
|
87
|
+
toolSchema: summaryToolSchema,
|
|
88
|
+
toolName: "record_session_summary",
|
|
89
|
+
toolDescription:
|
|
90
|
+
"Record the high-value learnings extracted from this session, plus per-memory ratings of any retrievals.",
|
|
91
|
+
systemPrompt:
|
|
92
|
+
"You are an expert at extracting durable, generalizable learnings from agent sessions.",
|
|
93
|
+
userPrompt,
|
|
94
|
+
callerTag: `session-summary:${opts.harness}`,
|
|
95
|
+
apiUrl: opts.apiUrl,
|
|
96
|
+
apiKey: opts.apiKey,
|
|
97
|
+
signal: opts.signal,
|
|
98
|
+
retries: 3,
|
|
99
|
+
...(opts._credentialOverride ? { _credentialOverride: opts._credentialOverride } : {}),
|
|
100
|
+
});
|
|
101
|
+
}
|