@cloc/provider-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.
Files changed (82) hide show
  1. package/LICENSE +21 -0
  2. package/dist/agent.d.ts +93 -0
  3. package/dist/agent.d.ts.map +1 -0
  4. package/dist/agent.js +359 -0
  5. package/dist/agent.js.map +1 -0
  6. package/dist/config.d.ts +85 -0
  7. package/dist/config.d.ts.map +1 -0
  8. package/dist/config.js +101 -0
  9. package/dist/config.js.map +1 -0
  10. package/dist/gateway.d.ts +74 -0
  11. package/dist/gateway.d.ts.map +1 -0
  12. package/dist/gateway.js +96 -0
  13. package/dist/gateway.js.map +1 -0
  14. package/dist/index.d.ts +47 -0
  15. package/dist/index.d.ts.map +1 -0
  16. package/dist/index.js +46 -0
  17. package/dist/index.js.map +1 -0
  18. package/dist/memory-tool.d.ts +63 -0
  19. package/dist/memory-tool.d.ts.map +1 -0
  20. package/dist/memory-tool.js +183 -0
  21. package/dist/memory-tool.js.map +1 -0
  22. package/dist/output.d.ts +49 -0
  23. package/dist/output.d.ts.map +1 -0
  24. package/dist/output.js +41 -0
  25. package/dist/output.js.map +1 -0
  26. package/dist/plugin.d.ts +74 -0
  27. package/dist/plugin.d.ts.map +1 -0
  28. package/dist/plugin.js +86 -0
  29. package/dist/plugin.js.map +1 -0
  30. package/dist/request.d.ts +82 -0
  31. package/dist/request.d.ts.map +1 -0
  32. package/dist/request.js +80 -0
  33. package/dist/request.js.map +1 -0
  34. package/dist/safety.d.ts +54 -0
  35. package/dist/safety.d.ts.map +1 -0
  36. package/dist/safety.js +0 -0
  37. package/dist/safety.js.map +1 -0
  38. package/dist/secrets.d.ts +51 -0
  39. package/dist/secrets.d.ts.map +1 -0
  40. package/dist/secrets.js +47 -0
  41. package/dist/secrets.js.map +1 -0
  42. package/dist/skills-loader.d.ts +76 -0
  43. package/dist/skills-loader.d.ts.map +1 -0
  44. package/dist/skills-loader.js +99 -0
  45. package/dist/skills-loader.js.map +1 -0
  46. package/dist/stream.d.ts +58 -0
  47. package/dist/stream.d.ts.map +1 -0
  48. package/dist/stream.js +59 -0
  49. package/dist/stream.js.map +1 -0
  50. package/dist/tokens.d.ts +17 -0
  51. package/dist/tokens.d.ts.map +1 -0
  52. package/dist/tokens.js +17 -0
  53. package/dist/tokens.js.map +1 -0
  54. package/dist/tool-loop.d.ts +98 -0
  55. package/dist/tool-loop.d.ts.map +1 -0
  56. package/dist/tool-loop.js +210 -0
  57. package/dist/tool-loop.js.map +1 -0
  58. package/dist/trace.d.ts +78 -0
  59. package/dist/trace.d.ts.map +1 -0
  60. package/dist/trace.js +39 -0
  61. package/dist/trace.js.map +1 -0
  62. package/dist/validate.d.ts +54 -0
  63. package/dist/validate.d.ts.map +1 -0
  64. package/dist/validate.js +81 -0
  65. package/dist/validate.js.map +1 -0
  66. package/package.json +55 -0
  67. package/src/agent.ts +487 -0
  68. package/src/config.ts +147 -0
  69. package/src/gateway.ts +126 -0
  70. package/src/index.ts +101 -0
  71. package/src/memory-tool.ts +219 -0
  72. package/src/output.ts +67 -0
  73. package/src/plugin.ts +123 -0
  74. package/src/request.ts +178 -0
  75. package/src/safety.ts +0 -0
  76. package/src/secrets.ts +71 -0
  77. package/src/skills-loader.ts +153 -0
  78. package/src/stream.ts +80 -0
  79. package/src/tokens.ts +82 -0
  80. package/src/tool-loop.ts +268 -0
  81. package/src/trace.ts +87 -0
  82. package/src/validate.ts +118 -0
package/src/tokens.ts ADDED
@@ -0,0 +1,82 @@
1
+ /**
2
+ * @cloc/provider-ai-sdk · tokens.ts — re-export the AgentProvider ref this adapter answers,
3
+ * and pull in the vendor-free contract TYPES from @cloc/core (FR-002, data-model §1).
4
+ *
5
+ * This is the ONLY seam the adapter binds: `AgentProviderRef` selects exactly one
6
+ * AgentProvider per environment (§32 / §75.3). Downstream modules type-check against the
7
+ * core contract — never against an AI SDK / AI Gateway type — so no vendor type leaks across
8
+ * the public signature (FR-002, Constitution Principle 8).
9
+ *
10
+ * NOTE: the model is a swappable FIELD (`GenOpts.model: ModelRef`), never part of this
11
+ * contract; the token/package/capability names carry no "model" (FR-014).
12
+ */
13
+
14
+ // The token (value) the kernel resolves this plugin against. EXACTLY ONE wins (§32).
15
+ export { AgentProviderRef } from "@cloc/core";
16
+
17
+ // The vendor-free contract + result types every internal module builds on (no vendor edge).
18
+ export type {
19
+ AgentProvider,
20
+ GenOpts,
21
+ Prompt,
22
+ StructuredPrompt,
23
+ Output,
24
+ OutputPayload,
25
+ Delta,
26
+ PlanNode,
27
+ ModelRef,
28
+ StandardSchema,
29
+ StandardSchemaV1,
30
+ ProviderRef,
31
+ } from "@cloc/core";
32
+
33
+ // --- The agentic-primitives contract this runner EXECUTES (027-agentic-primitives) -------------
34
+ //
35
+ // These shapes are owned by @cloc/core (`src/agentic/*`): the render-time Agent's three optional,
36
+ // gated primitives — Skills (progressive disclosure), Memory (the memory-tool interface), and the
37
+ // budgeted Tool loop — plus the §58 gate hook. The runner consumes them as TYPES only; the loop
38
+ // that EXECUTES them (the AI SDK 6 `ToolLoopAgent`) lives in THIS package (tool-loop.ts /
39
+ // memory-tool.ts / skills-loader.ts) with the single vendor edge. No vendor type leaks across the
40
+ // core seam (FR-016, CON-001, Principle 8).
41
+ export type {
42
+ // Skills (§16b.1)
43
+ SkillRef,
44
+ SkillManifest,
45
+ SkillBody,
46
+ BundledResource,
47
+ DisclosureLevel,
48
+ // Memory (§16b.2)
49
+ MemoryStore,
50
+ MemoryBackend,
51
+ MemoryOp,
52
+ // Tools (§16b.3)
53
+ ToolDef,
54
+ ToolSet as CoreToolSet,
55
+ ToolSource,
56
+ // Loop / budget / prepareStep (§16b.3, §9.1)
57
+ ToolLoop,
58
+ LoopOpts,
59
+ StopCondition,
60
+ StepContext,
61
+ StepDirective,
62
+ PrepareStep,
63
+ // Gate (§58)
64
+ PolicyGateHook,
65
+ PrimitiveAccess,
66
+ GateDecision,
67
+ } from "@cloc/core";
68
+
69
+ // Agentic runtime helpers (pure, vendor-free) the runner reuses from the core contract.
70
+ export {
71
+ isStopTool,
72
+ validateSkillManifest,
73
+ isValidSkillName,
74
+ isValidSkillDescription,
75
+ stepCountIs as coreStepCountIs,
76
+ hasToolCall as coreHasToolCall,
77
+ isLoopFinished as coreIsLoopFinished,
78
+ defaultStopCondition,
79
+ DEFAULT_STEP_COUNT,
80
+ MEMORY_ROLE,
81
+ ALLOW_ALL_GATE,
82
+ } from "@cloc/core";
@@ -0,0 +1,268 @@
1
+ /**
2
+ * @cloc/provider-ai-sdk · tool-loop.ts — the budgeted render-time tool loop over the AI SDK v6
3
+ * `ToolLoopAgent` surface (007-provider-ai-sdk; 027-agentic-primitives §16b.3, §17, §9.1).
4
+ *
5
+ * The render Agent runs a bounded `call → execute-tools → feed-results → repeat` loop: the model
6
+ * may CALL a declared tool, its result is FED BACK as DATA (through safety.ts), and the loop runs
7
+ * until `stopWhen` terminates it (default `stepCountIs(20)` — the SAME trajectory budget that bounds
8
+ * the `0` console agent, §9.1, FR-013). `prepareStep` runs BEFORE each step to swap the model,
9
+ * restrict `activeTools`/`toolChoice`, and select which skills/memory enter that step (FR-011). A
10
+ * tool WITHOUT `execute` is a STOP SIGNAL (the model emits the call and the loop ends). Every tool
11
+ * call clears the §58 policy gate BEFORE execution; a denial DEGRADES (FR-014, FR-021).
12
+ *
13
+ * AI SDK v6 surface (verified against the installed ai@6 .d.ts, not stale memory):
14
+ * - `ToolLoopAgent` is the default `Agent` impl; `tool()`/`dynamicTool({ description, inputSchema,
15
+ * execute })` (v6 renamed `parameters` → `inputSchema`); multi-step loops use
16
+ * `stopWhen: stepCountIs(n)` (v6 removed `maxSteps`); `hasToolCall(name)` / `isLoopFinished()`
17
+ * are the other stop conditions; `StopCondition<TOOLS>` is `(opts) => boolean | PromiseLike`;
18
+ * `prepareStep({ steps, stepNumber, model, messages }) => { model?, toolChoice?, activeTools? }`.
19
+ * - The non-streaming `generateText`/`streamText` calls in agent.ts already drive this loop via
20
+ * `tools` + `stopWhen`; this module assembles those vendor values from the core contract.
21
+ *
22
+ * Vendor edge: this is one of the THREE files (with memory-tool.ts, skills-loader.ts) that touch
23
+ * `ai`. The core's `StopCondition`/`PrepareStep`/`ToolDef` shapes stay vendor-free (@cloc/core).
24
+ */
25
+
26
+ import { dynamicTool, stepCountIs, hasToolCall, isLoopFinished } from "ai";
27
+ import type {
28
+ ToolSet as AiToolSet,
29
+ FlexibleSchema,
30
+ StopCondition as AiStopCondition,
31
+ PrepareStepFunction,
32
+ ToolChoice,
33
+ LanguageModel,
34
+ } from "ai";
35
+ import type { AgentTool } from "./request.js";
36
+ import type {
37
+ StopCondition,
38
+ PrepareStep,
39
+ StepDirective,
40
+ PolicyGateHook,
41
+ CoreToolSet,
42
+ ToolDef,
43
+ } from "./tokens.js";
44
+ import { frameToolResultAsData } from "./safety.js";
45
+
46
+ /** Observable loop event the trace + stream consume (data-model §4). */
47
+ export type LoopEvent =
48
+ | { kind: "tool-call"; tool: string; args: unknown }
49
+ | { kind: "tool-result"; tool: string; result: unknown };
50
+
51
+ /**
52
+ * The ONE gated-tool-execution path shared by the legacy ({@link buildToolSet}) and the 027 agentic
53
+ * ({@link buildAgenticToolSet}) tool sets — so both gate, emit, degrade, and frame IDENTICALLY (DRY;
54
+ * FR-014, FR-015, FR-021). Steps, in order:
55
+ * 1. emit a `tool-call` event (the trace/stream observe the call in order),
56
+ * 2. clear the §58 gate (when one is supplied) — a DENY degrades: emit a `tool-result` denial and
57
+ * return it framed as DATA (never throws, never bypasses, never runs the user fn),
58
+ * 3. run the user-supplied `invoke`,
59
+ * 4. emit a `tool-result` event and return the result framed as DATA (FR-003 feedback path).
60
+ *
61
+ * `gate` is OPTIONAL so the legacy path stays callable without the §58 wiring (current behavior).
62
+ */
63
+ function makeGatedToolExecute(
64
+ name: string,
65
+ // The user fn may return sync OR a Promise (core `ToolDef.execute` is `(i) => Promise<O> | O`);
66
+ // we always `await` so a synchronous tool is handled identically.
67
+ invoke: (args: unknown) => Promise<unknown> | unknown,
68
+ onEvent: (event: LoopEvent) => void,
69
+ gate: PolicyGateHook | undefined,
70
+ ): (args: unknown) => Promise<string> {
71
+ return async (args: unknown): Promise<string> => {
72
+ onEvent({ kind: "tool-call", tool: name, args });
73
+ if (gate) {
74
+ const decision = await gate.check({ kind: "tool", tool: name });
75
+ if (!decision.allow) {
76
+ const reason = decision.reason ?? `tool "${name}" denied`;
77
+ onEvent({ kind: "tool-result", tool: name, result: { denied: true, reason } });
78
+ // Degrade: hand the model an attributable denial as DATA (never crash, never bypass).
79
+ return frameToolResultAsData(name, { denied: true, reason });
80
+ }
81
+ }
82
+ const result = await invoke(args);
83
+ onEvent({ kind: "tool-result", tool: name, result });
84
+ // The observation re-enters the loop as DATA, never as instructions (FR-015).
85
+ return frameToolResultAsData(name, result);
86
+ };
87
+ }
88
+
89
+ /**
90
+ * Default loop bound — the SAME trajectory budget that bounds the `0` console agent (§9.1). v6's
91
+ * own default is `stepCountIs(20)`; we keep a smaller render default but the render layer MUST set
92
+ * `stopWhen` explicitly (FR-013). Kept for the legacy single-turn path; the agentic render path
93
+ * maps `GenOpts.stopWhen` through {@link toAiStopWhen}.
94
+ */
95
+ export const DEFAULT_MAX_STEPS = 8;
96
+
97
+ /**
98
+ * Build the AI SDK v6 `ToolSet` from the turn's legacy declared tools (the pre-027 shape on
99
+ * `AgentTurn.tools`). Each tool's `execute`:
100
+ * 1. emits a `tool-call` event,
101
+ * 2. invokes the user-provided `invoke` (the actual DataSource query / declared action),
102
+ * 3. frames the result as DATA (safety.ts) and emits a `tool-result` event,
103
+ * 4. returns the framed result to the loop as the observation (FR-003 feedback path).
104
+ *
105
+ * `onEvent` lets the agent forward `tool-call`/`tool-result` to the stream + trace in order. When a
106
+ * {@link PolicyGateHook} is supplied, each call clears the §58 gate FIRST and a denial DEGRADES
107
+ * (the tool returns an attributable denial as DATA, never throwing — FR-014, FR-021).
108
+ */
109
+ export function buildToolSet(
110
+ tools: ReadonlyArray<AgentTool>,
111
+ onEvent: (event: LoopEvent) => void,
112
+ gate?: PolicyGateHook,
113
+ ): AiToolSet {
114
+ const set: AiToolSet = {};
115
+ for (const t of tools) {
116
+ // v6: runtime tools whose input/output types are not known at dev time use `dynamicTool`
117
+ // (the typed `tool({...})` overloads infer INPUT/OUTPUT from a static schema we don't have).
118
+ set[t.name] = dynamicTool({
119
+ ...(t.description !== undefined ? { description: t.description } : {}),
120
+ // v6: `inputSchema` (was `parameters`). Zod / Standard-Schema validators are accepted.
121
+ inputSchema: t.input as FlexibleSchema<unknown>,
122
+ // v6 `execute(input, options)` — we ignore the options (no abort/streaming hooks here).
123
+ // Gating + eventing + DATA-framing run through the ONE shared gated executor (DRY).
124
+ execute: makeGatedToolExecute(t.name, (args) => t.invoke(args), onEvent, gate),
125
+ });
126
+ }
127
+ return set;
128
+ }
129
+
130
+ /**
131
+ * Build the AI SDK v6 `ToolSet` from the 027 core {@link CoreToolSet} (`GenOpts.tools`) — the three
132
+ * sources (`plugin` / `capability` / `wired`) all join the render loop (FR-012). Each `ToolDef`'s
133
+ * `execute` is gate-checked then framed as DATA; a tool WITHOUT `execute` is registered as a STOP
134
+ * SIGNAL (v6: a tool with no `execute` ends the loop — §16b.3, isStopTool). Returns `{}` for an
135
+ * empty/undefined set so a baseline render adds no tools (FR-002).
136
+ */
137
+ export function buildAgenticToolSet(
138
+ tools: CoreToolSet | undefined,
139
+ onEvent: (event: LoopEvent) => void,
140
+ gate: PolicyGateHook,
141
+ ): AiToolSet {
142
+ if (!tools) return {};
143
+ const set: AiToolSet = {};
144
+ for (const [name, def] of Object.entries(tools)) {
145
+ set[name] = agenticTool(name, def, onEvent, gate);
146
+ }
147
+ return set;
148
+ }
149
+
150
+ /** One core {@link ToolDef} → a gated v6 `dynamicTool`. `execute`-less = stop signal (§16b.3). */
151
+ function agenticTool(
152
+ name: string,
153
+ def: ToolDef,
154
+ onEvent: (event: LoopEvent) => void,
155
+ gate: PolicyGateHook,
156
+ ): AiToolSet[string] {
157
+ const base = {
158
+ ...(def.description !== undefined ? { description: def.description } : {}),
159
+ inputSchema: def.inputSchema as unknown as FlexibleSchema<unknown>,
160
+ };
161
+ // A tool WITHOUT execute is a STOP SIGNAL: register a dynamic tool with NO execute so the loop
162
+ // ends when the model emits the call (AI SDK v6 semantics; isStopTool narrows it). `dynamicTool`
163
+ // requires `execute`, so a stop tool is registered as the equivalent dynamic-tool object literal.
164
+ const execute = def.execute;
165
+ if (execute === undefined) {
166
+ return { ...base, type: "dynamic" } as unknown as AiToolSet[string];
167
+ }
168
+ return dynamicTool({
169
+ ...base,
170
+ // Same gated executor as the legacy path — gate-check, emit, degrade-to-DATA, frame (DRY).
171
+ execute: makeGatedToolExecute(name, (args) => execute(args), onEvent, gate),
172
+ });
173
+ }
174
+
175
+ /** The v6 multi-step stop condition (replaces the removed `maxSteps`). Legacy single-turn default. */
176
+ export function stopAfter(maxSteps: number = DEFAULT_MAX_STEPS): AiStopCondition<AiToolSet> {
177
+ return stepCountIs(maxSteps);
178
+ }
179
+
180
+ /**
181
+ * Map the core vendor-free {@link StopCondition} (`GenOpts.stopWhen`) to the AI SDK v6 stop
182
+ * condition — `stepCountIs(n)` / `hasToolCall(name)` / `isLoopFinished()` (§16b.3, §9.1). When the
183
+ * render layer sets no budget we fall back to the loop default so it can never thrash unbounded
184
+ * (FR-013, NFR-005).
185
+ *
186
+ * TODO(027/003/012, NEEDS CLARIFICATION): cache keying of a tool-using (non-pure) render — whether
187
+ * tool outputs fold into `dataVersion`, the render is uncacheable, or it is cached per tool-result
188
+ * hash. Owned by 003/012; routed to Governance.
189
+ * TODO(027, NEEDS CLARIFICATION): degraded-render behavior when the budget is exhausted BEFORE a
190
+ * complete `Output` (cheaper tier? best partial-but-valid plan?). §16b.3 guarantees boundedness but
191
+ * not the exact fallback; today we let `validateOrRepair` (validate.ts) reject an incomplete plan so
192
+ * an invalid/partial structure is NEVER emitted (Principle 3). Routed to Governance.
193
+ */
194
+ export function toAiStopWhen(
195
+ stopWhen: StopCondition | undefined,
196
+ fallbackSteps: number = DEFAULT_MAX_STEPS,
197
+ ): AiStopCondition<AiToolSet> {
198
+ if (!stopWhen) return stepCountIs(fallbackSteps);
199
+ switch (stopWhen.kind) {
200
+ case "stepCount":
201
+ // Guard a non-finite/negative step count from a hand-built condition so the loop stays bounded.
202
+ return stepCountIs(Number.isFinite(stopWhen.n) && stopWhen.n > 0 ? Math.trunc(stopWhen.n) : fallbackSteps);
203
+ case "hasToolCall":
204
+ return hasToolCall(stopWhen.name);
205
+ case "isLoopFinished":
206
+ return isLoopFinished();
207
+ default: {
208
+ // Exhaustiveness: a new core StopCondition kind must be handled here. Fall back to the bounded
209
+ // step cap so an unrecognized condition can never leave the loop unbounded (NFR-005).
210
+ const _exhaustive: never = stopWhen;
211
+ void _exhaustive;
212
+ return stepCountIs(fallbackSteps);
213
+ }
214
+ }
215
+ }
216
+
217
+ /**
218
+ * Adapt the core vendor-free {@link PrepareStep} (per-step context engineering, FR-011) to the AI
219
+ * SDK v6 `prepareStep` callback. Runs BEFORE each step and returns the per-step overrides v6
220
+ * honors: `model` (swap), `activeTools` (restrict), `toolChoice` (restrict). Skill/memory SELECTION
221
+ * carried on the core {@link StepDirective} is applied by the caller (it owns the typed tool/skill
222
+ * maps); v6's surface only takes the model/tools knobs, so those pass straight through. Returns
223
+ * `undefined` (use outer settings) when the directive sets nothing for the step.
224
+ *
225
+ * `resolveModel` turns a core `ModelRef`-bearing directive into the routed v6 `LanguageModel`; when
226
+ * absent the step keeps the outer model. The per-step skill/memory selection is surfaced via
227
+ * `onDirective` so the runner can re-frame the prompt fragment / gate the chosen skills.
228
+ */
229
+ export function toAiPrepareStep(
230
+ prepareStep: PrepareStep | undefined,
231
+ resolveModel: (directive: StepDirective) => PrepareStepResultModel | undefined,
232
+ onDirective?: (step: number, directive: StepDirective) => void,
233
+ ): PrepareStepFunction<AiToolSet> | undefined {
234
+ if (!prepareStep) return undefined;
235
+ return ({ stepNumber, steps }) => {
236
+ const directive = prepareStep({
237
+ step: stepNumber,
238
+ ...(steps.length > 0 ? { lastOutput: steps[steps.length - 1] } : {}),
239
+ });
240
+ onDirective?.(stepNumber, directive);
241
+ const model = resolveModel(directive);
242
+ const result: {
243
+ model?: PrepareStepResultModel;
244
+ activeTools?: Array<string>;
245
+ toolChoice?: ToolChoice<AiToolSet>;
246
+ } = {};
247
+ if (model !== undefined) result.model = model;
248
+ if (directive.activeTools !== undefined) result.activeTools = [...directive.activeTools];
249
+ if (directive.toolChoice !== undefined) result.toolChoice = toAiToolChoice(directive.toolChoice);
250
+ return Object.keys(result).length > 0 ? (result as never) : undefined;
251
+ };
252
+ }
253
+
254
+ /**
255
+ * Map the core vendor-free tool-choice (a string like `"auto"`/`"required"`/`"none"`, or a named
256
+ * `{ tool }` selector) to the AI SDK v6 {@link ToolChoice} shape (`"auto"` | `{ type: "tool",
257
+ * toolName }`). Extracted so the mapping lives in ONE place (DRY); behavior is unchanged.
258
+ */
259
+ function toAiToolChoice(
260
+ choice: NonNullable<StepDirective["toolChoice"]>,
261
+ ): ToolChoice<AiToolSet> {
262
+ return typeof choice === "string"
263
+ ? (choice as ToolChoice<AiToolSet>)
264
+ : { type: "tool", toolName: choice.tool };
265
+ }
266
+
267
+ /** The v6 `LanguageModel` a per-step model swap resolves to (kept vendor-internal). */
268
+ export type PrepareStepResultModel = LanguageModel;
package/src/trace.ts ADDED
@@ -0,0 +1,87 @@
1
+ /**
2
+ * @cloc/provider-ai-sdk · trace.ts — the `agent.generate` OTel subtree (FR-012, §72.2,
3
+ * contracts/trace.contract.ts).
4
+ *
5
+ * The render pipeline contributes the STRUCTURAL spans (route/ground/project/cache/eval); the
6
+ * AgentProvider contributes the `agent.generate` subtree, which the AI SDK + AI Gateway emit
7
+ * natively (provider, model, fallback hops, token counts, cost). This module pins the attribute
8
+ * names and NESTS the subtree under the active pipeline trace so the two compose into one trace =
9
+ * one replay recipe (§72.2).
10
+ *
11
+ * To stay runtime-agnostic and vendor-light, the adapter does not hard-depend on
12
+ * `@opentelemetry/api`; it records against a small structural `SpanSink` the pipeline/host backs
13
+ * with its real tracer (the AI SDK already emits OTel — the host wires `experimental_telemetry`).
14
+ * A no-op sink is the default so the adapter runs without a tracer present.
15
+ *
16
+ * TODO(C3 — spec Clarification 3): whether `prompt.tokens`/`output.tokens` (and latency) are
17
+ * MANDATED at MVP, or `cost.usd` is the only required metric. §72.2 shows tokens; routed to
18
+ * Governance (research.md C3). They are recorded when available, asserted only for cost.
19
+ */
20
+
21
+ /** The `agent.generate` attribute bag (contracts/trace.contract.ts). */
22
+ export interface AgentGenerateAttributes {
23
+ "gateway.provider": string;
24
+ "gateway.model": string;
25
+ /** Failover hops; 0 when the primary served. */
26
+ "gateway.fallback": number;
27
+ /** Per-request cost — REQUIRED (FR-012). */
28
+ "cost.usd": number;
29
+ /** §72.2 shows these; mandate is TODO(C3). Recorded when the SDK/gateway reports them. */
30
+ "prompt.tokens"?: number;
31
+ "output.tokens"?: number;
32
+ /** Total tokens for the turn (v6 `usage.totalTokens`); recorded additively when reported. */
33
+ "total.tokens"?: number;
34
+ /** Cached input tokens read (v6 `usage.inputTokenDetails.cacheReadTokens`); when reported. */
35
+ "cache.read.tokens"?: number;
36
+ }
37
+
38
+ /** Shared provenance keys carried with the pipeline spans so the trace is a replay recipe. */
39
+ export interface AgentGenerateContext {
40
+ "cloc.data_version": string;
41
+ "cloc.kit_version": string;
42
+ "cloc.seed"?: string;
43
+ "cloc.tier": 2 | 3;
44
+ }
45
+
46
+ /** Span events: the loop + validation boundary as observable events. */
47
+ export type AgentGenerateEvent =
48
+ | { name: "tool.call"; tool: string }
49
+ | { name: "tool.result"; tool: string }
50
+ | { name: "validate"; ok: boolean }
51
+ | { name: "repair"; attempt: number; ok: boolean };
52
+
53
+ /**
54
+ * The minimal structural sink the adapter records against. The host backs this with its real OTel
55
+ * tracer (nesting under `req.trace`); the default is a no-op. Mirrors the OTel span surface the
56
+ * adapter needs without importing the vendor package (keeps the adapter dependency-light, §43).
57
+ */
58
+ export interface SpanSink {
59
+ setAttributes(attrs: Partial<AgentGenerateAttributes & AgentGenerateContext>): void;
60
+ addEvent(event: AgentGenerateEvent): void;
61
+ /** Mark the subtree failed (carries the surfaced AgentError code). */
62
+ recordError(code: string, message: string): void;
63
+ end(): void;
64
+ }
65
+
66
+ /** A no-op sink — used when no tracer is wired (the adapter still runs). */
67
+ export const NOOP_SPAN: SpanSink = {
68
+ setAttributes() {},
69
+ addEvent() {},
70
+ recordError() {},
71
+ end() {},
72
+ };
73
+
74
+ /**
75
+ * Open the `agent.generate` span under the active pipeline trace. `start` is supplied by the host
76
+ * (it owns the real tracer + the active OTel context from `req.trace`); when absent we return the
77
+ * no-op sink. The returned sink is the seam every internal module reports through.
78
+ */
79
+ export function startAgentSpan(
80
+ context: AgentGenerateContext,
81
+ start?: (name: "agent.generate", context: AgentGenerateContext) => SpanSink,
82
+ ): SpanSink {
83
+ if (!start) return NOOP_SPAN;
84
+ const span = start("agent.generate", context);
85
+ span.setAttributes(context);
86
+ return span;
87
+ }
@@ -0,0 +1,118 @@
1
+ /**
2
+ * @cloc/provider-ai-sdk · validate.ts — the validate-or-repair boundary (FR-005, NFR-001, §3).
3
+ *
4
+ * Probabilistic PLANNING is allowed; probabilistic final OUTPUT is not (Constitution Principle 3).
5
+ * The produced object is validated against the kit's Standard Schema (`req.outputSchema`, Zod).
6
+ * On failure a BOUNDED repair loop runs (`RepairPolicy.maxAttempts`); on exhaustion the request is
7
+ * REJECTED with a fatal `validation-exhausted` error — a structurally invalid object NEVER passes
8
+ * through to the client (FR-005, edge case, quickstart §6).
9
+ *
10
+ * TODO(C2 — spec Clarification 2): `maxAttempts` is provisional (research.md C2, config.ts
11
+ * DEFAULT_REPAIR_POLICY). The doc says "validate / repair" with no limit; routed to Governance.
12
+ */
13
+
14
+ import { validate as runStandardValidate } from "@cloc/core";
15
+ import type { StandardSchemaV1 } from "./tokens.js";
16
+ import type { RepairPolicy } from "./config.js";
17
+ import { AgentError } from "./gateway.js";
18
+
19
+ /** One validation failure (mirrors the Standard-Schema issue shape). */
20
+ export interface ValidationIssue {
21
+ message: string;
22
+ path?: ReadonlyArray<PropertyKey | { key: PropertyKey }>;
23
+ }
24
+
25
+ /** Outcome of one validate pass. */
26
+ export type ValidateOutcome<T> =
27
+ | { ok: true; value: T }
28
+ | { ok: false; issues: ReadonlyArray<ValidationIssue> };
29
+
30
+ /** Run the kit schema's own validator once (no repair). Async-normalized. */
31
+ export async function validateOnce<T>(
32
+ schema: StandardSchemaV1<unknown, T>,
33
+ candidate: unknown,
34
+ ): Promise<ValidateOutcome<T>> {
35
+ const result = await runStandardValidate(schema, candidate);
36
+ if (result.issues === undefined) {
37
+ return { ok: true, value: result.value };
38
+ }
39
+ return { ok: false, issues: result.issues };
40
+ }
41
+
42
+ /** Reported to the trace/agent on each repair attempt (data-model §6 events). */
43
+ export interface ValidateEvents {
44
+ onValidate?(ok: boolean): void;
45
+ onRepair?(attempt: number, ok: boolean): void;
46
+ }
47
+
48
+ /**
49
+ * Validate `initial`; if invalid, ask `repair(issues, attempt)` for a corrected candidate, up to
50
+ * `policy.maxAttempts` times. The first valid candidate is returned. On exhaustion, REJECT with a
51
+ * fatal `validation-exhausted` error — never return an invalid object (FR-005, NFR-001).
52
+ *
53
+ * `repair` is injected by the Agent: it re-prompts the model with the validation issues. Returning
54
+ * `undefined` (the model gave up / no repair channel) short-circuits to rejection.
55
+ */
56
+ /** How many issue messages to surface in the rejection summary (the rest are summarized as a count). */
57
+ export const ISSUE_SUMMARY_LIMIT = 3;
58
+
59
+ export async function validateOrRepair<T>(args: {
60
+ schema: StandardSchemaV1<unknown, T>;
61
+ initial: unknown;
62
+ policy: RepairPolicy;
63
+ repair: (issues: ReadonlyArray<ValidationIssue>, attempt: number) => Promise<unknown | undefined>;
64
+ events?: ValidateEvents;
65
+ }): Promise<T> {
66
+ const { schema, initial, policy, repair, events } = args;
67
+
68
+ // A non-finite / negative bound is a config error; clamp to a sane floor so the loop is finite
69
+ // and at least validates the original once (never an infinite or skipped loop). Defaults preserve
70
+ // current behavior for the documented `maxAttempts >= 1` configs.
71
+ const maxAttempts = Number.isFinite(policy.maxAttempts) ? Math.max(0, Math.trunc(policy.maxAttempts)) : 0;
72
+
73
+ let candidate: unknown = initial;
74
+ let lastIssues: ReadonlyArray<ValidationIssue> = [];
75
+
76
+ // attempt 0 is the original; attempts 1..maxAttempts are repairs.
77
+ for (let attempt = 0; attempt <= maxAttempts; attempt++) {
78
+ const outcome = await validateOnce(schema, candidate);
79
+ const ok = outcome.ok;
80
+ if (attempt === 0) events?.onValidate?.(ok);
81
+ else events?.onRepair?.(attempt, ok);
82
+
83
+ if (outcome.ok) return outcome.value;
84
+ lastIssues = outcome.issues;
85
+
86
+ if (attempt === maxAttempts) break; // bound reached → reject below
87
+ const next = await repair(outcome.issues, attempt + 1);
88
+ if (next === undefined) break; // no repair produced → reject
89
+ candidate = next;
90
+ }
91
+
92
+ // onExhaustion is "reject" by contract; invalid output is never passed through (FR-005).
93
+ throw new AgentError(
94
+ "validation-exhausted",
95
+ `output failed kit-schema validation after ${maxAttempts} repair attempt(s): ${summarizeIssues(lastIssues)}`,
96
+ true,
97
+ );
98
+ }
99
+
100
+ /**
101
+ * Summarize validation issues for the rejection message: the first {@link ISSUE_SUMMARY_LIMIT}
102
+ * messages joined, plus a `(+N more)` tail when there are more. Each message is annotated with its
103
+ * dotted path when present so the failure is actionable (e.g. `title: Required`).
104
+ */
105
+ function summarizeIssues(issues: ReadonlyArray<ValidationIssue>): string {
106
+ if (issues.length === 0) return "(no issue detail reported)";
107
+ const head = issues.slice(0, ISSUE_SUMMARY_LIMIT).map(formatIssue).join("; ");
108
+ const extra = issues.length - ISSUE_SUMMARY_LIMIT;
109
+ return extra > 0 ? `${head} (+${extra} more)` : head;
110
+ }
111
+
112
+ /** Format one issue as `path: message` (or just `message` when it has no path). */
113
+ function formatIssue(issue: ValidationIssue): string {
114
+ const path = (issue.path ?? [])
115
+ .map((seg) => (typeof seg === "object" && seg !== null ? String(seg.key) : String(seg)))
116
+ .join(".");
117
+ return path ? `${path}: ${issue.message}` : issue.message;
118
+ }