agent-accelerator 0.3.0 → 0.4.5
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 +1 -1
- package/src/agent/agent.ts +845 -116
- package/src/agent/context.ts +35 -0
- package/src/agent/delegation.ts +390 -36
- package/src/agent/loop.ts +785 -110
- package/src/agent/subagent.ts +59 -0
- package/src/index.ts +173 -59
- package/src/models/catalog-cache.ts +68 -25
- package/src/models/catalog.ts +123 -18
- package/src/providers/google.ts +152 -43
- package/src/providers/openai-compat.ts +246 -96
- package/src/providers/openai.ts +274 -79
- package/src/providers/openrouter.ts +191 -82
- package/src/providers/registry.ts +16 -12
- package/src/providers/shared.ts +190 -0
- package/src/providers.ts +259 -9
- package/src/session/store.ts +521 -33
- package/src/streaming/event-stream.ts +156 -41
- package/src/tools/executor.ts +248 -15
- package/src/tools/tool.ts +147 -3
- package/src/types/agent.ts +244 -2
- package/src/types/errors.ts +133 -0
- package/src/types/model.ts +22 -1
- package/src/types/response.ts +133 -3
- package/src/types/tool.ts +69 -0
- package/src/update-models.ts +8 -2
- package/src/utils/cache.ts +68 -1
- package/src/utils/env.ts +23 -13
- package/src/utils/errors.ts +15 -1
- package/src/utils/headers.ts +153 -19
- package/src/utils/retry.ts +27 -13
- package/src/utils/session.ts +284 -0
- package/src/providers/openrouter-responses.ts +0 -949
- package/src/types/provider-payloads.ts +0 -352
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared provider helpers (Q-45-partial).
|
|
3
|
+
*
|
|
4
|
+
* Single home for the `redactedHeaders` / `readErrorPayload` /
|
|
5
|
+
* `parseArguments` / `normalizeThinkingParts` helpers previously duplicated
|
|
6
|
+
* across the four REST transports, plus timeout/signal and id helpers.
|
|
7
|
+
*/
|
|
8
|
+
import { toConciseProviderError } from "../utils/errors.ts";
|
|
9
|
+
|
|
10
|
+
/** Parses a JSON tool-argument string; non-objects degrade to `{ raw }`. */
|
|
11
|
+
export function parseArguments(raw: string | undefined): Record<string, unknown> {
|
|
12
|
+
if (!raw) return {};
|
|
13
|
+
try {
|
|
14
|
+
const parsed: unknown = JSON.parse(raw);
|
|
15
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
16
|
+
return parsed as Record<string, unknown>;
|
|
17
|
+
}
|
|
18
|
+
return { raw };
|
|
19
|
+
} catch {
|
|
20
|
+
return { raw };
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Trims/collapses assembled thinking parts (no edge-tripling). */
|
|
25
|
+
export function normalizeThinkingParts(parts: string[]): string | undefined {
|
|
26
|
+
const cleaned = parts
|
|
27
|
+
.map((p) => p.replace(/\n{3,}/g, "\n\n").trim())
|
|
28
|
+
.filter((p) => p.length > 0);
|
|
29
|
+
return cleaned.length > 0 ? cleaned.join("\n") : undefined;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Redacts secret-bearing headers for the raw audit trail. Superset of the
|
|
34
|
+
* four adapter versions: `authorization` (any case) and `x-goog-api-key` are
|
|
35
|
+
* redacted; all other headers pass through untouched.
|
|
36
|
+
*/
|
|
37
|
+
export function redactedHeaders(headers: Record<string, string>): Record<string, string> {
|
|
38
|
+
const out: Record<string, string> = {};
|
|
39
|
+
for (const [k, v] of Object.entries(headers)) {
|
|
40
|
+
const lower = k.toLowerCase();
|
|
41
|
+
out[k] = lower === "authorization" || lower === "x-goog-api-key" ? "[REDACTED]" : v;
|
|
42
|
+
}
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Extracts `{ message, code, errorType, raw }` from a non-2xx JSON body. Handles
|
|
48
|
+
* OpenAI `type`, OpenRouter `metadata.error_type` + `metadata.raw`, and Google
|
|
49
|
+
* `status` code shapes; unknown bodies degrade to a 300-char slice.
|
|
50
|
+
*/
|
|
51
|
+
export function readErrorPayload(
|
|
52
|
+
bodyText: string
|
|
53
|
+
): { message: string; code?: string | number; errorType?: string; raw?: string } {
|
|
54
|
+
try {
|
|
55
|
+
const parsed: unknown = JSON.parse(bodyText);
|
|
56
|
+
const first = Array.isArray(parsed) ? parsed[0] : parsed;
|
|
57
|
+
const err = (first as { error?: { message?: string; code?: string | number; type?: string; status?: string | number; metadata?: { error_type?: string; raw?: unknown } } })?.error;
|
|
58
|
+
if (err && typeof err.message === "string") {
|
|
59
|
+
const code = err.code ?? err.status;
|
|
60
|
+
const errorType =
|
|
61
|
+
err.type ?? err.metadata?.error_type ?? (first as { error_type?: string })?.error_type;
|
|
62
|
+
const rawCandidate =
|
|
63
|
+
(err as { metadata?: { raw?: unknown } }).metadata?.raw ??
|
|
64
|
+
(first as { metadata?: { raw?: unknown } })?.metadata?.raw;
|
|
65
|
+
const raw = typeof rawCandidate === "string" && rawCandidate.trim() ? rawCandidate.trim().slice(0, 500) : undefined;
|
|
66
|
+
return {
|
|
67
|
+
message: err.message,
|
|
68
|
+
...(code !== undefined ? { code } : {}),
|
|
69
|
+
...(typeof errorType === "string" ? { errorType } : {}),
|
|
70
|
+
...(raw ? { raw } : {}),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
return { message: bodyText.slice(0, 300) };
|
|
74
|
+
} catch {
|
|
75
|
+
return { message: bodyText.slice(0, 300) };
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Retryable incomplete-stream error for truncated SSE (Q-23). */
|
|
80
|
+
export function incompleteStreamError(provider: string, modelId: string): Error {
|
|
81
|
+
const err = toConciseProviderError(
|
|
82
|
+
Object.assign(new Error("Stream ended without terminal event"), {
|
|
83
|
+
statusCode: 502,
|
|
84
|
+
transient: true,
|
|
85
|
+
retryable: true,
|
|
86
|
+
url: provider,
|
|
87
|
+
}),
|
|
88
|
+
provider,
|
|
89
|
+
modelId
|
|
90
|
+
);
|
|
91
|
+
(err as any).code = "incomplete_stream";
|
|
92
|
+
return err;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Per-request timeout ms from provider options (Q-22). */
|
|
96
|
+
export function timeoutFor(options: { timeoutMs?: number } | undefined): number | undefined {
|
|
97
|
+
const t = (options as { timeoutMs?: unknown } | undefined)?.timeoutMs;
|
|
98
|
+
return typeof t === "number" && Number.isFinite(t) && t > 0 ? Math.floor(t) : undefined;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Merges a parent signal with a timeout into one effective signal. Caller must call cleanup when the request settles so the timer never holds the loop open. */
|
|
102
|
+
export function combinedSignal(signal?: AbortSignal, timeoutMs?: number): { signal: AbortSignal | undefined; cleanup: () => void } {
|
|
103
|
+
if (timeoutMs === undefined) return { signal, cleanup: () => {} };
|
|
104
|
+
const ctrl = new AbortController();
|
|
105
|
+
const timer = setTimeout(() => {
|
|
106
|
+
try { ctrl.abort(); } catch {}
|
|
107
|
+
}, timeoutMs);
|
|
108
|
+
try { (timer as unknown as { unref?: () => void }).unref?.(); } catch {}
|
|
109
|
+
const cleanup = () => {
|
|
110
|
+
try { clearTimeout(timer); } catch {}
|
|
111
|
+
};
|
|
112
|
+
if (signal) {
|
|
113
|
+
if (signal.aborted) {
|
|
114
|
+
cleanup();
|
|
115
|
+
ctrl.abort();
|
|
116
|
+
} else {
|
|
117
|
+
signal.addEventListener("abort", () => {
|
|
118
|
+
cleanup();
|
|
119
|
+
try { ctrl.abort(); } catch {}
|
|
120
|
+
}, { once: true });
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return { signal: ctrl.signal, cleanup };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Q-34: crypto-backed tool-call id, no Math.random primary. */
|
|
127
|
+
export function newToolCallId(prefix = "call"): string {
|
|
128
|
+
try {
|
|
129
|
+
const c = (globalThis as any)?.crypto;
|
|
130
|
+
if (c?.randomUUID) return `${prefix}_${c.randomUUID().replace(/-/g, "").slice(0, 12)}`;
|
|
131
|
+
} catch {}
|
|
132
|
+
try {
|
|
133
|
+
const nc: any = (globalThis as any)?.process?.getBuiltinModule?.("node:crypto") ?? null;
|
|
134
|
+
if (nc?.randomUUID) return `${prefix}_${nc.randomUUID().replace(/-/g, "").slice(0, 12)}`;
|
|
135
|
+
} catch {}
|
|
136
|
+
return `${prefix}_${Math.random().toString(36).slice(2, 10)}`;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* True when custom headers already carry auth (proxy/Copilot flows).
|
|
141
|
+
* Case-insensitive, non-empty `Authorization` or `cf-aig-authorization`.
|
|
142
|
+
* Callers return `"unused"` instead of throwing missing-key.
|
|
143
|
+
*/
|
|
144
|
+
export function hasAuthHeader(headers: Record<string, string> | undefined): boolean {
|
|
145
|
+
if (!headers) return false;
|
|
146
|
+
for (const [k, v] of Object.entries(headers)) {
|
|
147
|
+
const lower = k.toLowerCase();
|
|
148
|
+
if ((lower === "authorization" || lower === "cf-aig-authorization") && typeof v === "string" && v.trim().length > 0) {
|
|
149
|
+
return true;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** True for truncated streamed tool args (`{ raw }` marker from `parseStreamedToolArguments`). */
|
|
156
|
+
export function hasTruncatedToolArguments(args: unknown): boolean {
|
|
157
|
+
if (!args || typeof args !== "object" || Array.isArray(args)) return false;
|
|
158
|
+
const keys = Object.keys(args as Record<string, unknown>);
|
|
159
|
+
return keys.length === 1 && keys[0] === "raw" && typeof (args as Record<string, unknown>).raw === "string";
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Short hash for tool-ID clamping (FNV-1a, 8 hex chars, no deps). */
|
|
163
|
+
export function shortHashId(value: string): string {
|
|
164
|
+
let h = 0x811c9dc5;
|
|
165
|
+
for (let i = 0; i < value.length; i++) {
|
|
166
|
+
h ^= value.charCodeAt(i);
|
|
167
|
+
h = Math.imul(h, 0x01000193);
|
|
168
|
+
}
|
|
169
|
+
return (h >>> 0).toString(16).padStart(8, "0");
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Clamps a tool-call ID to the 40-char OpenAI limit (pi `api/openai-completions.ts:1194`).
|
|
174
|
+
* Pipe IDs (`callId|itemId`) become `callId_itemHash`; overlong IDs truncate
|
|
175
|
+
* with hash suffix. Plain IDs pass through (truncated if needed).
|
|
176
|
+
*/
|
|
177
|
+
export function clampToolCallId(id: string): string {
|
|
178
|
+
const clean = String(id ?? "").replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
179
|
+
if (!clean.includes("|")) {
|
|
180
|
+
return clean.length > 40 ? clean.slice(0, 40) : clean || "call_unknown";
|
|
181
|
+
}
|
|
182
|
+
const sep = clean.indexOf("|");
|
|
183
|
+
const callId = clean.slice(0, sep).replace(/_+$/, "") || "call";
|
|
184
|
+
const itemId = clean.slice(sep + 1).replace(/_+$/, "");
|
|
185
|
+
const combined = itemId ? `${callId}_${itemId}` : callId;
|
|
186
|
+
if (combined.length <= 40) return combined;
|
|
187
|
+
const hash = shortHashId(id).slice(0, 8);
|
|
188
|
+
const prefix = callId.slice(0, Math.max(1, 40 - hash.length - 1));
|
|
189
|
+
return `${prefix}_${hash}`;
|
|
190
|
+
}
|
package/src/providers.ts
CHANGED
|
@@ -10,6 +10,9 @@
|
|
|
10
10
|
* redesigning this abstraction — only new per-provider mappers/adapters.
|
|
11
11
|
*/
|
|
12
12
|
import type { CacheConfig, ServiceTier, ThinkingLevel } from "./types/core.ts";
|
|
13
|
+
import type { StructuredOutputSpec } from "./types/model.ts";
|
|
14
|
+
import { zodToJsonSchema } from "./tools/schema.ts";
|
|
15
|
+
import { ValidationError } from "./types/errors.ts";
|
|
13
16
|
|
|
14
17
|
/** Classification of a canonical capability on a given provider. */
|
|
15
18
|
export type ProviderCapabilityStatus =
|
|
@@ -50,9 +53,8 @@ export function emitProviderWarning(options: {
|
|
|
50
53
|
// Leading newline: warnings fire at the start of a turn's request build,
|
|
51
54
|
// when the previous turn's streamed text may have left the terminal cursor
|
|
52
55
|
// mid-line. Without it the warning glues onto streamed output.
|
|
53
|
-
console.warn(
|
|
54
|
-
|
|
55
|
-
);
|
|
56
|
+
// Q-41 dual: telemetry bus (new) + console.warn (compat for existing captureWarnings tests; /cli logger replaces in 0.5).
|
|
57
|
+
{ const msg = `[Agent Accelerator] WARNING [${options.provider}] ${options.capability}${requested}: ${options.reason} Using ${options.fallback} instead.`; try { (globalThis as any).__agentAccelTelemetry?.emitWarning?.({ code: "provider_warning", provider: options.provider, capability: options.capability, message: msg }); } catch {} try { (globalThis as any).__agentAccelWarnSink?.(msg, options); } catch {} try { console.warn(msg); } catch {} }
|
|
56
58
|
}
|
|
57
59
|
|
|
58
60
|
/** Clears deduped-warning state (mainly for tests). */
|
|
@@ -184,7 +186,7 @@ export function mapThinkingLevelToOpenRouterChat(
|
|
|
184
186
|
* echoed but are NOT documented levels, so they clamp to `high` with a warning.
|
|
185
187
|
*
|
|
186
188
|
* @deprecated The Responses skin is discontinued for OpenRouter
|
|
187
|
-
* (
|
|
189
|
+
* (removed from core). Use
|
|
188
190
|
* {@link mapThinkingLevelToOpenRouterChat} with the stable Chat Completions
|
|
189
191
|
* transport instead.
|
|
190
192
|
*/
|
|
@@ -358,17 +360,17 @@ export function mapServiceTierToOpenAI(tier: ServiceTier | undefined): "flex" |
|
|
|
358
360
|
|
|
359
361
|
/**
|
|
360
362
|
* Applies canonical cache config for OpenAI. Session affinity flows via
|
|
361
|
-
* `prompt_cache_key` (handled by the adapter);
|
|
362
|
-
*
|
|
363
|
-
*
|
|
364
|
-
* are UNSUPPORTED: warn and drop.
|
|
363
|
+
* `prompt_cache_key` (handled by the adapter); `long` retention maps to
|
|
364
|
+
* `prompt_cache_retention: "24h"` when the catalog supports it (handled by
|
|
365
|
+
* the adapter, pi `api/openai-responses.ts:97` rule). Other explicit
|
|
366
|
+
* retention modes and `cachedContentId` are UNSUPPORTED: warn and drop.
|
|
365
367
|
*/
|
|
366
368
|
export function applyCacheForOpenAI(
|
|
367
369
|
cache: CacheConfig | undefined,
|
|
368
370
|
modelRef: string
|
|
369
371
|
): void {
|
|
370
372
|
if (!cache) return;
|
|
371
|
-
if (cache.retention && cache.retention !== "implicit") {
|
|
373
|
+
if (cache.retention && cache.retention !== "implicit" && cache.retention !== "long") {
|
|
372
374
|
emitProviderWarning({
|
|
373
375
|
provider: "openai",
|
|
374
376
|
capability: "cache retention",
|
|
@@ -490,3 +492,251 @@ export function parseStreamedToolArguments(startText: string, deltaText: string)
|
|
|
490
492
|
}
|
|
491
493
|
return { raw: startText + deltaText };
|
|
492
494
|
}
|
|
495
|
+
|
|
496
|
+
// ---------------------------------------------------------------------------
|
|
497
|
+
// Structured outputs (canonical, pure, no I/O)
|
|
498
|
+
// ---------------------------------------------------------------------------
|
|
499
|
+
|
|
500
|
+
function isZodLike(value: unknown): boolean {
|
|
501
|
+
if (!value || typeof value !== "object") return false;
|
|
502
|
+
const v = value as Record<string, unknown>;
|
|
503
|
+
return (
|
|
504
|
+
typeof v["safeParse"] === "function" ||
|
|
505
|
+
typeof v["parse"] === "function" ||
|
|
506
|
+
"_def" in v ||
|
|
507
|
+
"~standard" in v
|
|
508
|
+
);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function looksLikeJsonSchema(value: unknown): boolean {
|
|
512
|
+
if (!isPlainObject(value)) return false;
|
|
513
|
+
const v = value as Record<string, unknown>;
|
|
514
|
+
return (
|
|
515
|
+
typeof v["type"] === "string" ||
|
|
516
|
+
isPlainObject(v["properties"]) ||
|
|
517
|
+
Array.isArray(v["anyOf"]) ||
|
|
518
|
+
Array.isArray(v["oneOf"]) ||
|
|
519
|
+
Array.isArray(v["allOf"]) ||
|
|
520
|
+
typeof v["$schema"] === "string" ||
|
|
521
|
+
isPlainObject(v["$defs"]) ||
|
|
522
|
+
isPlainObject(v["definitions"])
|
|
523
|
+
);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function sanitizeOutputName(raw: unknown): string {
|
|
527
|
+
const base = typeof raw === "string" && raw.trim() ? raw.trim() : "structured_output";
|
|
528
|
+
const cleaned = base.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 64) || "structured_output";
|
|
529
|
+
return cleaned;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function schemaToJsonSchema(schema: unknown, what: string): Record<string, unknown> {
|
|
533
|
+
if (isZodLike(schema)) {
|
|
534
|
+
try {
|
|
535
|
+
const converted = zodToJsonSchema(schema);
|
|
536
|
+
if (isPlainObject(converted)) return converted;
|
|
537
|
+
} catch {
|
|
538
|
+
// fall through to error below
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
if (isPlainObject(schema)) return schema as Record<string, unknown>;
|
|
542
|
+
throw new ValidationError(
|
|
543
|
+
`[Agent Accelerator] Invalid structured output ${what}: expected a Zod schema, a JSON Schema object, or { name, schema, strict }.`
|
|
544
|
+
);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* Normalizes `AgentConfig.output` into a provider-agnostic spec.
|
|
549
|
+
*
|
|
550
|
+
* Accepts a Zod schema, a JSON Schema object, or an explicit
|
|
551
|
+
* `{ name?, description?, schema, strict? }` wrapper. Returns `undefined`
|
|
552
|
+
* when no output was requested. Throws `ValidationError` for invalid shapes.
|
|
553
|
+
*
|
|
554
|
+
* @example `normalizeStructuredOutput(z.object({ city: z.string() }))`
|
|
555
|
+
*/
|
|
556
|
+
export function normalizeStructuredOutput(output: unknown): StructuredOutputSpec | undefined {
|
|
557
|
+
if (output === undefined || output === null) return undefined;
|
|
558
|
+
if (isPlainObject(output) && "schema" in (output as Record<string, unknown>)) {
|
|
559
|
+
const rec = output as Record<string, unknown>;
|
|
560
|
+
const schema = schemaToJsonSchema(rec["schema"], "`output.schema`");
|
|
561
|
+
return {
|
|
562
|
+
name: sanitizeOutputName(rec["name"]),
|
|
563
|
+
schema,
|
|
564
|
+
strict: typeof rec["strict"] === "boolean" ? (rec["strict"] as boolean) : true,
|
|
565
|
+
...(typeof rec["description"] === "string" && (rec["description"] as string).trim()
|
|
566
|
+
? { description: (rec["description"] as string).trim() }
|
|
567
|
+
: {}),
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
if (isZodLike(output) || looksLikeJsonSchema(output)) {
|
|
571
|
+
return {
|
|
572
|
+
name: "structured_output",
|
|
573
|
+
schema: schemaToJsonSchema(output, "`output`"),
|
|
574
|
+
strict: true,
|
|
575
|
+
};
|
|
576
|
+
}
|
|
577
|
+
throw new ValidationError(
|
|
578
|
+
"[Agent Accelerator] Invalid structured output: expected a Zod schema, a JSON Schema object, or { name, schema, strict }."
|
|
579
|
+
);
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* Extracts the original Zod validator from a canonical output option, when
|
|
584
|
+
* present (bare Zod schema or `{ schema: Zod }`). Used by the loop for
|
|
585
|
+
* post-parse validation; `undefined` means JSON-parse only.
|
|
586
|
+
*/
|
|
587
|
+
export function extractOutputValidator(output: unknown): { safeParse: (v: unknown) => { success: boolean; data?: unknown; error?: { message: string } } } | undefined {
|
|
588
|
+
if (!output) return undefined;
|
|
589
|
+
if (isZodLike(output) && typeof (output as { safeParse?: unknown }).safeParse === "function") {
|
|
590
|
+
return output as { safeParse: (v: unknown) => { success: boolean; data?: unknown; error?: { message: string } } };
|
|
591
|
+
}
|
|
592
|
+
if (isPlainObject(output) && isZodLike((output as Record<string, unknown>)["schema"])) {
|
|
593
|
+
const inner = (output as Record<string, unknown>)["schema"] as { safeParse?: unknown };
|
|
594
|
+
if (typeof inner.safeParse === "function") {
|
|
595
|
+
return inner as { safeParse: (v: unknown) => { success: boolean; data?: unknown; error?: { message: string } } };
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
return undefined;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
function stripCodeFences(text: string): string {
|
|
602
|
+
const trimmed = text.trim();
|
|
603
|
+
const fenced = trimmed.match(/^```(?:json)?\s*\n([\s\S]*?)\n```\s*$/i);
|
|
604
|
+
if (fenced && typeof fenced[1] === "string") return fenced[1].trim();
|
|
605
|
+
return trimmed;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/**
|
|
609
|
+
* Parses and validates a final answer against a canonical output option.
|
|
610
|
+
* Strips markdown fences, requires valid JSON, then applies the Zod validator
|
|
611
|
+
* when the original option carried one. Throws `ValidationError` on mismatch.
|
|
612
|
+
*
|
|
613
|
+
* @example `parseStructuredOutput('{"city":"Paris"}', z.object({ city: z.string() }))`
|
|
614
|
+
*/
|
|
615
|
+
export function parseStructuredOutput(text: string, output: unknown): unknown {
|
|
616
|
+
const cleaned = stripCodeFences(typeof text === "string" ? text : String(text ?? ""));
|
|
617
|
+
if (!cleaned) {
|
|
618
|
+
throw new ValidationError(
|
|
619
|
+
"[Agent Accelerator] Structured output validation failed: model returned empty text, expected JSON matching the output schema."
|
|
620
|
+
);
|
|
621
|
+
}
|
|
622
|
+
let parsed: unknown;
|
|
623
|
+
try {
|
|
624
|
+
parsed = JSON.parse(cleaned);
|
|
625
|
+
} catch {
|
|
626
|
+
throw new ValidationError(
|
|
627
|
+
`[Agent Accelerator] Structured output validation failed: model text is not valid JSON (${cleaned.slice(0, 120)}).`
|
|
628
|
+
);
|
|
629
|
+
}
|
|
630
|
+
const validator = extractOutputValidator(output);
|
|
631
|
+
if (validator) {
|
|
632
|
+
const res = validator.safeParse(parsed);
|
|
633
|
+
if (!res.success) {
|
|
634
|
+
const detail = res.error?.message ? ` ${res.error.message}`.slice(0, 500) : "";
|
|
635
|
+
throw new ValidationError(
|
|
636
|
+
`[Agent Accelerator] Structured output validation failed: JSON does not match the output schema.${detail}`
|
|
637
|
+
);
|
|
638
|
+
}
|
|
639
|
+
return res.data ?? parsed;
|
|
640
|
+
}
|
|
641
|
+
return parsed;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/**
|
|
645
|
+
* Maps a normalized spec onto OpenAI Responses `text.format`.
|
|
646
|
+
* `{ type: json_schema, name, schema, strict }` (docs: responses/create).
|
|
647
|
+
*/
|
|
648
|
+
export function mapOutputToOpenAI(
|
|
649
|
+
spec: StructuredOutputSpec | undefined
|
|
650
|
+
): { format: { type: "json_schema"; name: string; schema: Record<string, unknown>; strict: boolean; description?: string } } | undefined {
|
|
651
|
+
if (!spec) return undefined;
|
|
652
|
+
return {
|
|
653
|
+
format: {
|
|
654
|
+
type: "json_schema",
|
|
655
|
+
name: spec.name,
|
|
656
|
+
schema: spec.schema,
|
|
657
|
+
strict: spec.strict ?? true,
|
|
658
|
+
...(spec.description ? { description: spec.description } : {}),
|
|
659
|
+
},
|
|
660
|
+
};
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/**
|
|
664
|
+
* Maps a normalized spec onto Google Interactions `response_format`.
|
|
665
|
+
* `{ type: text, mime_type: application/json, schema }` (docs: structured-output).
|
|
666
|
+
*/
|
|
667
|
+
export function mapOutputToGoogle(
|
|
668
|
+
spec: StructuredOutputSpec | undefined
|
|
669
|
+
): { responseFormat: { type: "text"; mime_type: "application/json"; schema: Record<string, unknown> } } | undefined {
|
|
670
|
+
if (!spec) return undefined;
|
|
671
|
+
return {
|
|
672
|
+
responseFormat: {
|
|
673
|
+
type: "text",
|
|
674
|
+
mime_type: "application/json",
|
|
675
|
+
schema: spec.schema,
|
|
676
|
+
},
|
|
677
|
+
};
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
/** Chat Completions `response_format` wire shape (OpenRouter + compat). */
|
|
681
|
+
export type ChatCompletionsResponseFormat =
|
|
682
|
+
| { type: "json_schema"; json_schema: { name: string; schema: Record<string, unknown>; strict: boolean; description?: string } };
|
|
683
|
+
|
|
684
|
+
/**
|
|
685
|
+
* Maps a normalized spec onto Chat Completions `response_format`.
|
|
686
|
+
* `{ type: json_schema, json_schema: { name, schema, strict } }`
|
|
687
|
+
* (docs: openai completions/create, openrouter parameters).
|
|
688
|
+
*/
|
|
689
|
+
export function mapOutputToOpenRouterChat(
|
|
690
|
+
spec: StructuredOutputSpec | undefined
|
|
691
|
+
): { response_format: ChatCompletionsResponseFormat } | undefined {
|
|
692
|
+
if (!spec) return undefined;
|
|
693
|
+
return {
|
|
694
|
+
response_format: {
|
|
695
|
+
type: "json_schema",
|
|
696
|
+
json_schema: {
|
|
697
|
+
name: spec.name,
|
|
698
|
+
schema: spec.schema,
|
|
699
|
+
strict: spec.strict ?? true,
|
|
700
|
+
...(spec.description ? { description: spec.description } : {}),
|
|
701
|
+
},
|
|
702
|
+
},
|
|
703
|
+
};
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/**
|
|
707
|
+
* Maps a normalized spec onto generic compat Chat Completions
|
|
708
|
+
* `response_format` (same standard shape as OpenRouter Chat).
|
|
709
|
+
*/
|
|
710
|
+
export function mapOutputToCompatChat(
|
|
711
|
+
spec: StructuredOutputSpec | undefined
|
|
712
|
+
): { response_format: ChatCompletionsResponseFormat } | undefined {
|
|
713
|
+
return mapOutputToOpenRouterChat(spec);
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
/**
|
|
717
|
+
* Builds the emulated structured-output instruction for models without
|
|
718
|
+
* native `response_format` support (catalog `structured_output: false`).
|
|
719
|
+
* Appended to the system prompt so the model still targets JSON; the loop
|
|
720
|
+
* still validates into `AgentResponse.parsed`.
|
|
721
|
+
*
|
|
722
|
+
* @example `augmentSystemPromptWithStructuredOutput(sys, spec)`
|
|
723
|
+
*/
|
|
724
|
+
export function buildStructuredOutputInstruction(spec: StructuredOutputSpec): string {
|
|
725
|
+
return `Return ONLY valid JSON matching this JSON Schema, with no markdown fences, no commentary, no extra text.\nSchema: ${JSON.stringify(spec.schema)}`;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
/**
|
|
729
|
+
* Returns a system prompt with the emulated structured-output instruction
|
|
730
|
+
* appended (stable per Agent since `output` is agent-level, so prefix-cache
|
|
731
|
+
* affinity is preserved). No-op when spec is undefined.
|
|
732
|
+
*/
|
|
733
|
+
export function augmentSystemPromptWithStructuredOutput(
|
|
734
|
+
systemPrompt: string | undefined,
|
|
735
|
+
spec: StructuredOutputSpec | undefined
|
|
736
|
+
): string | undefined {
|
|
737
|
+
if (!spec) return systemPrompt;
|
|
738
|
+
const instruction = `[Structured Output]\n${buildStructuredOutputInstruction(spec)}`;
|
|
739
|
+
if (!systemPrompt) return instruction;
|
|
740
|
+
if (systemPrompt.includes("[Structured Output]")) return systemPrompt;
|
|
741
|
+
return `${systemPrompt}\n\n${instruction}`;
|
|
742
|
+
}
|