@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.
- package/LICENSE +21 -0
- package/dist/agent.d.ts +93 -0
- package/dist/agent.d.ts.map +1 -0
- package/dist/agent.js +359 -0
- package/dist/agent.js.map +1 -0
- package/dist/config.d.ts +85 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +101 -0
- package/dist/config.js.map +1 -0
- package/dist/gateway.d.ts +74 -0
- package/dist/gateway.d.ts.map +1 -0
- package/dist/gateway.js +96 -0
- package/dist/gateway.js.map +1 -0
- package/dist/index.d.ts +47 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +46 -0
- package/dist/index.js.map +1 -0
- package/dist/memory-tool.d.ts +63 -0
- package/dist/memory-tool.d.ts.map +1 -0
- package/dist/memory-tool.js +183 -0
- package/dist/memory-tool.js.map +1 -0
- package/dist/output.d.ts +49 -0
- package/dist/output.d.ts.map +1 -0
- package/dist/output.js +41 -0
- package/dist/output.js.map +1 -0
- package/dist/plugin.d.ts +74 -0
- package/dist/plugin.d.ts.map +1 -0
- package/dist/plugin.js +86 -0
- package/dist/plugin.js.map +1 -0
- package/dist/request.d.ts +82 -0
- package/dist/request.d.ts.map +1 -0
- package/dist/request.js +80 -0
- package/dist/request.js.map +1 -0
- package/dist/safety.d.ts +54 -0
- package/dist/safety.d.ts.map +1 -0
- package/dist/safety.js +0 -0
- package/dist/safety.js.map +1 -0
- package/dist/secrets.d.ts +51 -0
- package/dist/secrets.d.ts.map +1 -0
- package/dist/secrets.js +47 -0
- package/dist/secrets.js.map +1 -0
- package/dist/skills-loader.d.ts +76 -0
- package/dist/skills-loader.d.ts.map +1 -0
- package/dist/skills-loader.js +99 -0
- package/dist/skills-loader.js.map +1 -0
- package/dist/stream.d.ts +58 -0
- package/dist/stream.d.ts.map +1 -0
- package/dist/stream.js +59 -0
- package/dist/stream.js.map +1 -0
- package/dist/tokens.d.ts +17 -0
- package/dist/tokens.d.ts.map +1 -0
- package/dist/tokens.js +17 -0
- package/dist/tokens.js.map +1 -0
- package/dist/tool-loop.d.ts +98 -0
- package/dist/tool-loop.d.ts.map +1 -0
- package/dist/tool-loop.js +210 -0
- package/dist/tool-loop.js.map +1 -0
- package/dist/trace.d.ts +78 -0
- package/dist/trace.d.ts.map +1 -0
- package/dist/trace.js +39 -0
- package/dist/trace.js.map +1 -0
- package/dist/validate.d.ts +54 -0
- package/dist/validate.d.ts.map +1 -0
- package/dist/validate.js +81 -0
- package/dist/validate.js.map +1 -0
- package/package.json +55 -0
- package/src/agent.ts +487 -0
- package/src/config.ts +147 -0
- package/src/gateway.ts +126 -0
- package/src/index.ts +101 -0
- package/src/memory-tool.ts +219 -0
- package/src/output.ts +67 -0
- package/src/plugin.ts +123 -0
- package/src/request.ts +178 -0
- package/src/safety.ts +0 -0
- package/src/secrets.ts +71 -0
- package/src/skills-loader.ts +153 -0
- package/src/stream.ts +80 -0
- package/src/tokens.ts +82 -0
- package/src/tool-loop.ts +268 -0
- package/src/trace.ts +87 -0
- package/src/validate.ts +118 -0
package/src/plugin.ts
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @cloc/provider-ai-sdk · plugin.ts — the manifest + `provides` wiring (FR-001, FR-010, FR-014,
|
|
3
|
+
* data-model §7, contracts/cloc-config.schema.json).
|
|
4
|
+
*
|
|
5
|
+
* `definePlugin(...)` PROVIDES the `AgentProviderRef` token with `AiSdkAgent`, so the kernel
|
|
6
|
+
* resolves this as the DEFAULT AgentProvider with no `agent:` block required for resolution
|
|
7
|
+
* (FR-001). Exactly one AgentProvider wins per environment; selecting a different one by token
|
|
8
|
+
* replaces this with NO edit to this plugin or @cloc/core (FR-010, NFR-004). The least-privilege
|
|
9
|
+
* `needs` (gateway host + the named secret) is declared so the kernel grants only those (FR-009).
|
|
10
|
+
*
|
|
11
|
+
* Naming carries NO "model" — the token is `AgentProviderRef`, the package is `provider-ai-sdk`,
|
|
12
|
+
* the impl is `AiSdkAgent` (FR-014). The model is a `cloc.yml` `agent.model` FIELD.
|
|
13
|
+
*
|
|
14
|
+
* TODO(C1 — spec Clarification 1): whether a built-in default provider/model ships (so the
|
|
15
|
+
* `agent:` block is optional) vs. BYO before the first synthesis request. Routed to Governance.
|
|
16
|
+
* TODO(T031): the kernel threads the granted secret handle + parsed config through PluginContext
|
|
17
|
+
* at boot; until that capability surface lands in @cloc/plugin, the factory reads them off an
|
|
18
|
+
* augmented context (see `agentDepsFromContext`) and falls back to ambient resolution.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { definePlugin } from "@cloc/plugin";
|
|
22
|
+
import type { ProviderRegistration, PluginContext } from "@cloc/plugin";
|
|
23
|
+
import { AgentProviderRef } from "./tokens.js";
|
|
24
|
+
import type { PolicyGateHook } from "./tokens.js";
|
|
25
|
+
import { AiSdkAgent, type AgentDeps } from "./agent.js";
|
|
26
|
+
import { parseAgentConfig, type AgentConfig, PROVIDER_ID } from "./config.js";
|
|
27
|
+
import { needsFor, GATEWAY_HOST, type SecretHandle, type AgentNeeds } from "./secrets.js";
|
|
28
|
+
import type { SpanSink, AgentGenerateContext } from "./trace.js";
|
|
29
|
+
|
|
30
|
+
/** This package's name — no "model" in it (FR-014). */
|
|
31
|
+
export const PLUGIN_NAME = "@cloc/provider-ai-sdk";
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The boot-time surface the kernel supplies to the provider factory: the parsed `agent:` config,
|
|
35
|
+
* the granted by-name secret handle, and (optionally) the host's OTel span opener. Mirrors what
|
|
36
|
+
* T031 will thread through `PluginContext`; modeled here so the factory is typed end-to-end.
|
|
37
|
+
*/
|
|
38
|
+
export interface AgentBootContext {
|
|
39
|
+
/** Raw `cloc.yml` `agent:` block (parsed + validated here). */
|
|
40
|
+
agent?: unknown;
|
|
41
|
+
/** The kernel-granted, by-name secret handle (scoped to the declared `secretRef`). */
|
|
42
|
+
secret?: SecretHandle;
|
|
43
|
+
/** Optional OTel span opener backed by the pipeline's tracer (nests under req.trace). */
|
|
44
|
+
startSpan?: (name: "agent.generate", context: AgentGenerateContext) => SpanSink;
|
|
45
|
+
/**
|
|
46
|
+
* The §58 policy-before-execution gate (016-policy-gate) the kernel injects so every render-time
|
|
47
|
+
* tool/skill/memory access clears it BEFORE execution (027-agentic-primitives FR-014). When the
|
|
48
|
+
* kernel has not wired 016, the adapter falls back to an allow-all gate (tests/conformance only;
|
|
49
|
+
* a deny degrades rather than bypasses — FR-021).
|
|
50
|
+
*/
|
|
51
|
+
gate?: PolicyGateHook;
|
|
52
|
+
/**
|
|
53
|
+
* OPTIONAL override for how a repair re-prompt is framed (see {@link AgentDeps.frameRepairPrompt}).
|
|
54
|
+
* Threaded straight through to the Agent; when absent the adapter's default framing is used.
|
|
55
|
+
*/
|
|
56
|
+
frameRepairPrompt?: AgentDeps["frameRepairPrompt"];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** A by-name secret handle that always denies — the safe default before the kernel grants one. */
|
|
60
|
+
function denyingSecret(name: string): SecretHandle {
|
|
61
|
+
return { name, async resolve() { return undefined; } };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Resolve the boot dependencies for the Agent from the (augmented) plugin context. Parses the
|
|
66
|
+
* `agent:` block per the config schema and binds the granted secret handle. When the kernel has
|
|
67
|
+
* not yet threaded these (pre-T031), it falls back to a denying secret + an env-driven config so
|
|
68
|
+
* the adapter is constructible; a real synthesis request then surfaces a clear secret/config error.
|
|
69
|
+
*/
|
|
70
|
+
export function agentDepsFromContext(ctx: AgentBootContext): AgentDeps {
|
|
71
|
+
const config: AgentConfig = parseAgentConfig(ctx.agent ?? defaultAgentBlock());
|
|
72
|
+
const secret = ctx.secret ?? denyingSecret(config.secretRef);
|
|
73
|
+
return {
|
|
74
|
+
config,
|
|
75
|
+
secret,
|
|
76
|
+
...(ctx.startSpan ? { startSpan: ctx.startSpan } : {}),
|
|
77
|
+
// The §58 gate (016) every render-time primitive access clears; allow-all only when unwired.
|
|
78
|
+
...(ctx.gate ? { gate: ctx.gate } : {}),
|
|
79
|
+
// Optional repair-prompt framing override (defaults to the adapter's framing when unset).
|
|
80
|
+
...(ctx.frameRepairPrompt ? { frameRepairPrompt: ctx.frameRepairPrompt } : {}),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* A provisional default `agent:` block so the plugin is constructible with zero `cloc.yml` wiring
|
|
86
|
+
* for resolution (FR-001). It pins NO usable default model — `secretRef` names the gateway key and
|
|
87
|
+
* `model` MUST be set by the developer before the first synthesis request.
|
|
88
|
+
* TODO(C1): replace with a shipped default once Governance decides (research.md C1).
|
|
89
|
+
*/
|
|
90
|
+
function defaultAgentBlock(): unknown {
|
|
91
|
+
return {
|
|
92
|
+
provider: PROVIDER_ID,
|
|
93
|
+
model: { vendor: "anthropic", name: "claude-sonnet-4-5" }, // TODO(C1): provisional, not mandated
|
|
94
|
+
secretRef: "AI_GATEWAY_API_KEY",
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The least-privilege resource needs the manifest declares (gateway host + the named secret). */
|
|
99
|
+
export function declaredNeeds(ctx?: AgentBootContext): AgentNeeds {
|
|
100
|
+
const config = parseAgentConfig(ctx?.agent ?? defaultAgentBlock());
|
|
101
|
+
return needsFor(config);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** The single `provides` entry: the AgentProvider token → `AiSdkAgent` (one winner, §32). */
|
|
105
|
+
export const agentProvider: ProviderRegistration = {
|
|
106
|
+
ref: AgentProviderRef,
|
|
107
|
+
// The registry passes the (least-privilege) plugin context; we build deps + the Agent from it.
|
|
108
|
+
impl: (ctx: PluginContext) => new AiSdkAgent(agentDepsFromContext(ctx as AgentBootContext)),
|
|
109
|
+
lifetime: "singleton", // resolved ONCE at boot; the request path never re-decides (§75.3)
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The plugin value. `provides` the AgentProvider token (default winner, FR-001); the manifest
|
|
114
|
+
* `needs` (gateway host + named secret) is declared in package.json `cloc` and surfaced here for
|
|
115
|
+
* the kernel's grant check (FR-009, NFR-005).
|
|
116
|
+
*/
|
|
117
|
+
export const plugin = definePlugin({
|
|
118
|
+
name: PLUGIN_NAME,
|
|
119
|
+
provides: [agentProvider],
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
/** The gateway host the plugin egresses to — the only network `need` (hosted-first, FR-006). */
|
|
123
|
+
export { GATEWAY_HOST };
|
package/src/request.ts
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @cloc/provider-ai-sdk · request.ts — the adapter-internal turn request, and how it maps to the
|
|
3
|
+
* core's vendor-free `Prompt` / `GenOpts` surface (contracts/agent-provider.ts, data-model §2).
|
|
4
|
+
*
|
|
5
|
+
* The core `AgentProvider` contract is `generate(p: Prompt, o?: GenOpts)` / `stream(...)`. The
|
|
6
|
+
* richer per-turn inputs the design's contract sketch names (grounding, the kit `outputSchema`,
|
|
7
|
+
* tools, tier, trace) arrive through the STRUCTURED prompt + the `GenOpts.providerOptions`
|
|
8
|
+
* passthrough (GenOpts keeps tier/grammar internals OUT of the model contract, §75.3). This module
|
|
9
|
+
* normalizes both into one internal {@link AgentTurn} the agent modules consume.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { GenOpts, Prompt, StandardSchemaV1, ModelRef } from "./tokens.js";
|
|
13
|
+
import type {
|
|
14
|
+
SkillRef,
|
|
15
|
+
MemoryStore,
|
|
16
|
+
CoreToolSet,
|
|
17
|
+
StopCondition,
|
|
18
|
+
PrepareStep,
|
|
19
|
+
PolicyGateHook,
|
|
20
|
+
} from "./tokens.js";
|
|
21
|
+
import { modelRefToString } from "@cloc/core";
|
|
22
|
+
import type { GroundedContext } from "./safety.js";
|
|
23
|
+
|
|
24
|
+
/** A tool the plan→act→observe loop may invoke; its result is fed back as DATA (FR-003, FR-015). */
|
|
25
|
+
export interface AgentTool {
|
|
26
|
+
name: string;
|
|
27
|
+
description?: string;
|
|
28
|
+
/** Tool input schema (Standard Schema — Zod exposes `~standard`). */
|
|
29
|
+
input: StandardSchemaV1;
|
|
30
|
+
/** Result returned to the loop as DATA, never executed as instructions (FR-015). */
|
|
31
|
+
invoke(args: unknown): Promise<unknown>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The active trace context the `agent.generate` subtree attaches under (FR-012). */
|
|
35
|
+
export interface TraceContext {
|
|
36
|
+
readonly traceId: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The normalized internal turn the agent modules run. Assembled from the core `Prompt` + `GenOpts`
|
|
41
|
+
* by {@link toAgentTurn}; the kit schema / grounding / tools / tier / trace ride through the
|
|
42
|
+
* structured prompt and `providerOptions` passthrough (§75.3) so the core contract stays vendor-
|
|
43
|
+
* and tier-free.
|
|
44
|
+
*/
|
|
45
|
+
export interface AgentTurn<TPlan = unknown> {
|
|
46
|
+
/** The resolved intent (opaque to the core; concrete shape is feature 003). */
|
|
47
|
+
intent: unknown;
|
|
48
|
+
/** Grounded facts — DATA, never instructions (FR-015). */
|
|
49
|
+
grounding: GroundedContext;
|
|
50
|
+
/** The kit's Output schema the result is validated against (imported, 004-render-ir). */
|
|
51
|
+
outputSchema: StandardSchemaV1<unknown, TPlan>;
|
|
52
|
+
/** Legacy per-turn tools the loop may call (pre-027 `providerOptions.tools` shape). */
|
|
53
|
+
tools: ReadonlyArray<AgentTool>;
|
|
54
|
+
/** Synthesis tier (§72.1). */
|
|
55
|
+
tier: 2 | 3;
|
|
56
|
+
// --- Agentic primitives (v2, 027-agentic-primitives §16b) — all OPTIONAL (FR-002) ---
|
|
57
|
+
/** SKILL.md folders, three-level progressive disclosure (§16b.1). Only metadata enters the
|
|
58
|
+
* prompt by default; body/bundled load lazily on a match / on demand. */
|
|
59
|
+
skills?: readonly SkillRef[];
|
|
60
|
+
/** `.cloc/memory/` via the memory-tool interface, `unstorage`-backed (§16b.2). */
|
|
61
|
+
memory?: MemoryStore;
|
|
62
|
+
/** AI-SDK `tool()` defs / MCP servers joined to the bounded render loop (§16b.3). The three
|
|
63
|
+
* sources (`plugin`/`capability`/`wired`) all join here (FR-012). */
|
|
64
|
+
agenticTools?: CoreToolSet;
|
|
65
|
+
/** The trajectory budget that bounds the loop, e.g. `stepCountIs(N)` (§9.1, FR-013). */
|
|
66
|
+
stopWhen?: StopCondition;
|
|
67
|
+
/** Per-step context engineering callback (§16b.3, FR-011). Supplied via `providerOptions`. */
|
|
68
|
+
prepareStep?: PrepareStep;
|
|
69
|
+
/** The model FIELD for this turn (gateway provider/model), if the caller pinned one. */
|
|
70
|
+
model?: ModelRef;
|
|
71
|
+
/** Determinism seed (golden snapshots, §10 keys). */
|
|
72
|
+
seed?: number;
|
|
73
|
+
/** Soft cap on generated tokens (→ AI SDK `maxOutputTokens`). */
|
|
74
|
+
maxTokens?: number;
|
|
75
|
+
/** Cancellation (§ conformance C1 / A6). */
|
|
76
|
+
signal?: AbortSignal;
|
|
77
|
+
/** Whether the caller requested the streaming path. */
|
|
78
|
+
stream: boolean;
|
|
79
|
+
/** Active OTel trace to nest `agent.generate` under (FR-012). */
|
|
80
|
+
trace?: TraceContext;
|
|
81
|
+
/** Adapter passthrough verbatim (kit version, data version, etc.). */
|
|
82
|
+
providerOptions?: Readonly<Record<string, unknown>>;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The shape we read out of `GenOpts.providerOptions` (the runtime's per-turn binding). */
|
|
86
|
+
interface ProviderOptionsSlice {
|
|
87
|
+
outputSchema?: StandardSchemaV1;
|
|
88
|
+
grounding?: GroundedContext;
|
|
89
|
+
tools?: ReadonlyArray<AgentTool>;
|
|
90
|
+
tier?: 2 | 3;
|
|
91
|
+
trace?: TraceContext;
|
|
92
|
+
kitVersion?: string;
|
|
93
|
+
dataVersion?: string;
|
|
94
|
+
/** Per-step context engineering (§16b.3, FR-011) — a callback can't ride the typed GenOpts, so
|
|
95
|
+
* the runtime threads it through the opaque `providerOptions` passthrough (§75.3). */
|
|
96
|
+
prepareStep?: PrepareStep;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function readSlice(opts?: GenOpts): ProviderOptionsSlice {
|
|
100
|
+
return (opts?.providerOptions ?? {}) as ProviderOptionsSlice;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Pull the intent + grounding out of a structured prompt (a bare string prompt has neither). */
|
|
104
|
+
function readPrompt(p: Prompt): { intent: unknown; grounding?: GroundedContext } {
|
|
105
|
+
if (typeof p === "string") return { intent: p };
|
|
106
|
+
const grounding = p.grounding as GroundedContext | undefined;
|
|
107
|
+
return grounding ? { intent: p.intent, grounding } : { intent: p.intent };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const EMPTY_GROUNDING: GroundedContext = { facts: [] };
|
|
111
|
+
|
|
112
|
+
/** The default synthesis tier when the runtime does not pin one (§72.1). */
|
|
113
|
+
const DEFAULT_TIER = 2 as const;
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Normalize the synthesis tier from the (untyped) provider-options slice to the valid `2 | 3` set.
|
|
117
|
+
* An absent or out-of-range value defaults to {@link DEFAULT_TIER} so a malformed passthrough can
|
|
118
|
+
* never produce an invalid tier on the span/turn (defense-in-depth; preserves the prior default).
|
|
119
|
+
*/
|
|
120
|
+
function normalizeTier(tier: unknown): 2 | 3 {
|
|
121
|
+
return tier === 3 ? 3 : DEFAULT_TIER;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Coerce a grounding slice to a well-formed {@link GroundedContext} (an array of facts), or empty. */
|
|
125
|
+
function normalizeGrounding(g: GroundedContext | undefined): GroundedContext | undefined {
|
|
126
|
+
if (!g) return undefined;
|
|
127
|
+
return Array.isArray(g.facts) ? g : EMPTY_GROUNDING;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Normalize the core `Prompt` + `GenOpts` into the internal {@link AgentTurn}. The kit
|
|
132
|
+
* `outputSchema`, grounding, tools, tier and trace arrive via the structured prompt /
|
|
133
|
+
* `providerOptions` passthrough; the model/seed/maxTokens/signal arrive on `GenOpts` directly.
|
|
134
|
+
*
|
|
135
|
+
* Throws when no `outputSchema` is available — the turn MUST end in a validated structured Output
|
|
136
|
+
* (FR-004); without the kit schema there is nothing to validate against.
|
|
137
|
+
*/
|
|
138
|
+
export function toAgentTurn<TPlan = unknown>(
|
|
139
|
+
p: Prompt,
|
|
140
|
+
o: GenOpts | undefined,
|
|
141
|
+
stream: boolean,
|
|
142
|
+
): AgentTurn<TPlan> {
|
|
143
|
+
const slice = readSlice(o);
|
|
144
|
+
const fromPrompt = readPrompt(p);
|
|
145
|
+
const outputSchema = slice.outputSchema as StandardSchemaV1<unknown, TPlan> | undefined;
|
|
146
|
+
if (!outputSchema) {
|
|
147
|
+
throw new Error(
|
|
148
|
+
"provider-ai-sdk: missing kit outputSchema (GenOpts.providerOptions.outputSchema) — the turn must end in a validated structured Output (FR-004/FR-013)",
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
const grounding =
|
|
152
|
+
normalizeGrounding(fromPrompt.grounding) ?? normalizeGrounding(slice.grounding) ?? EMPTY_GROUNDING;
|
|
153
|
+
return {
|
|
154
|
+
intent: fromPrompt.intent,
|
|
155
|
+
grounding,
|
|
156
|
+
outputSchema,
|
|
157
|
+
tools: Array.isArray(slice.tools) ? slice.tools : [],
|
|
158
|
+
tier: normalizeTier(slice.tier),
|
|
159
|
+
...(o?.model !== undefined ? { model: o.model } : {}),
|
|
160
|
+
...(o?.seed !== undefined ? { seed: o.seed } : {}),
|
|
161
|
+
...(o?.maxTokens !== undefined ? { maxTokens: o.maxTokens } : {}),
|
|
162
|
+
...(o?.signal !== undefined ? { signal: o.signal } : {}),
|
|
163
|
+
stream,
|
|
164
|
+
...(slice.trace !== undefined ? { trace: slice.trace } : {}),
|
|
165
|
+
...(o?.providerOptions !== undefined ? { providerOptions: o.providerOptions } : {}),
|
|
166
|
+
// --- Agentic primitives (v2, 027) — surfaced from the extended GenOpts, all optional (FR-002).
|
|
167
|
+
...(o?.skills !== undefined ? { skills: o.skills } : {}),
|
|
168
|
+
...(o?.memory !== undefined ? { memory: o.memory } : {}),
|
|
169
|
+
...(o?.tools !== undefined ? { agenticTools: o.tools } : {}),
|
|
170
|
+
...(o?.stopWhen !== undefined ? { stopWhen: o.stopWhen } : {}),
|
|
171
|
+
...(slice.prepareStep !== undefined ? { prepareStep: slice.prepareStep } : {}),
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** The `provider/model` string a turn pins, if any (purely for logging / cache-key composition). */
|
|
176
|
+
export function turnModelString(turn: AgentTurn): string | undefined {
|
|
177
|
+
return turn.model !== undefined ? modelRefToString(turn.model) : undefined;
|
|
178
|
+
}
|
package/src/safety.ts
ADDED
|
Binary file
|
package/src/secrets.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @cloc/provider-ai-sdk · secrets.ts — the secrets-by-name boundary (FR-009, NFR-005, §73.2).
|
|
3
|
+
*
|
|
4
|
+
* The gateway credential is referenced BY NAME (`config.secretRef`) and resolved LAZILY through
|
|
5
|
+
* an injected secrets-provider handle — never read from `cloc.yml` / `AgentConfig`. The adapter
|
|
6
|
+
* declares a LEAST-PRIVILEGE `needs` descriptor (the gateway network host + the named secret);
|
|
7
|
+
* the kernel grants only those (Constitution Principle 9).
|
|
8
|
+
*
|
|
9
|
+
* This module owns no secrets-provider implementation — that is a host integration (env/vault/
|
|
10
|
+
* KMS). It mirrors the kernel's `SecretHandle` shape structurally so the kernel-supplied handle
|
|
11
|
+
* satisfies it without a runtime import of @cloc/kernel.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { AgentConfig } from "./config.js";
|
|
15
|
+
|
|
16
|
+
/** The gateway host the adapter talks to (the only network egress it `needs`). */
|
|
17
|
+
export const GATEWAY_HOST = "ai-gateway.vercel.sh";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* A live, by-name handle to ONE secret. Structurally identical to @cloc/kernel's `SecretHandle`
|
|
21
|
+
* — the kernel passes its handle straight in. Resolution is lazy; the value never touches config.
|
|
22
|
+
*/
|
|
23
|
+
export interface SecretHandle {
|
|
24
|
+
readonly name: string;
|
|
25
|
+
resolve(): Promise<string | undefined>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The least-privilege resource needs this plugin declares (FR-009, NFR-005). The kernel reads
|
|
30
|
+
* this off the manifest and grants exactly: egress to the gateway host + the one named secret.
|
|
31
|
+
*/
|
|
32
|
+
export interface AgentNeeds {
|
|
33
|
+
/** Network hosts the adapter may reach — hosted-first, gateway only. */
|
|
34
|
+
net: readonly string[];
|
|
35
|
+
/** Secret NAMES the adapter may resolve — exactly the configured `secretRef`. */
|
|
36
|
+
secrets: readonly string[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Build the least-privilege `needs` descriptor for a given config (gateway host + secretRef). */
|
|
40
|
+
export function needsFor(config: AgentConfig): AgentNeeds {
|
|
41
|
+
return { net: [GATEWAY_HOST], secrets: [config.secretRef] };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Resolve the gateway credential BY NAME through the injected handle. Returns `undefined` when
|
|
46
|
+
* the handle denies it (undeclared / not present) — callers MUST treat a missing credential as a
|
|
47
|
+
* boot/config error, never fabricate one. The value is read here and nowhere else (FR-009).
|
|
48
|
+
*
|
|
49
|
+
* An empty / whitespace-only resolution is treated as ABSENT (returns `undefined`): a blank env var
|
|
50
|
+
* is a misconfiguration, not a usable key — surfacing it as `undefined` lets the gateway fall back
|
|
51
|
+
* to ambient/OIDC resolution rather than sending an empty Bearer token (defense-in-depth, NFR-005).
|
|
52
|
+
*/
|
|
53
|
+
export async function resolveGatewayKey(handle: SecretHandle): Promise<string | undefined> {
|
|
54
|
+
const value = await handle.resolve();
|
|
55
|
+
if (value === undefined || value === null) return undefined;
|
|
56
|
+
const trimmed = String(value).trim();
|
|
57
|
+
return trimmed.length > 0 ? trimmed : undefined;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Guard: assert no secret VALUE leaked into the config surface. `parseAgentConfig` already
|
|
62
|
+
* rejects an inlined value in `secretRef`; this is a belt-and-braces check usable at boot.
|
|
63
|
+
*/
|
|
64
|
+
export function assertNoInlineSecret(config: AgentConfig): void {
|
|
65
|
+
// `secretRef` is the ONLY credential field; it is a name (enforced in config.ts). There is no
|
|
66
|
+
// field on AgentConfig that carries a value, so a leaked value is structurally impossible here.
|
|
67
|
+
// Kept as an explicit invariant the conformance suite can assert against (data-model §5 rule).
|
|
68
|
+
if (config.secretRef.length === 0) {
|
|
69
|
+
throw new Error("agent.secretRef is required (the gateway credential NAME) — FR-009");
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @cloc/provider-ai-sdk · skills-loader.ts — the three-level progressive-disclosure loader for
|
|
3
|
+
* `SKILL.md` skills the render Agent activates (027-agentic-primitives §16b.1; FR-004, FR-005,
|
|
4
|
+
* FR-006, FR-014, NFR-008).
|
|
5
|
+
*
|
|
6
|
+
* Skills load by Discovery → Activation → Execution:
|
|
7
|
+
* - level 1 (Discovery): ONLY `manifest.name` + `manifest.description` enter the render prompt;
|
|
8
|
+
* - level 2 (Activation): the full SKILL.md Markdown body loads ONLY when a request matches;
|
|
9
|
+
* - level 3 (Execution): bundled scripts/references/templates are read or run ON DEMAND — the
|
|
10
|
+
* script's SOURCE is NEVER loaded into the model context, and executing a
|
|
11
|
+
* bundled script clears the §58 policy gate FIRST (FR-014).
|
|
12
|
+
*
|
|
13
|
+
* The SKILL.md *recipe* (front-matter + body + references) is a committed FACT under `.cloc/skills/`
|
|
14
|
+
* (§13.1, §45); any CODE a skill bakes is cached OUTSIDE the repo and never committed (FR-006). This
|
|
15
|
+
* loader exposes the recipe and routes bake/exec through a repo-EXTERNAL path, never writing code
|
|
16
|
+
* back into the data repo (Principle 1).
|
|
17
|
+
*
|
|
18
|
+
* Vendor edge: NONE. This module turns the core's vendor-free `SkillRef` shapes (@cloc/core) into
|
|
19
|
+
* the level-1 prompt fragment + the gated activation/execution helpers the loop uses; the model SDK
|
|
20
|
+
* is touched only in tool-loop.ts.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import type {
|
|
24
|
+
SkillRef,
|
|
25
|
+
SkillBody,
|
|
26
|
+
SkillManifest,
|
|
27
|
+
BundledResource,
|
|
28
|
+
PolicyGateHook,
|
|
29
|
+
GateDecision,
|
|
30
|
+
} from "./tokens.js";
|
|
31
|
+
|
|
32
|
+
/** The level-1 metadata that is the ONLY skill surface entering the render prompt by default. */
|
|
33
|
+
export interface SkillMetadataLine {
|
|
34
|
+
readonly id: string;
|
|
35
|
+
readonly name: string;
|
|
36
|
+
readonly description: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Render the level-1 (Discovery) skill metadata for the prompt — `name` + `description` ONLY, never
|
|
41
|
+
* the body or bundled bytes (§16b.1, FR-005). This is the "just enough for the model to know when
|
|
42
|
+
* each skill should be used" fragment; it keeps the prompt small (the ~84% token reduction §16b.2).
|
|
43
|
+
*/
|
|
44
|
+
export function skillMetadata(skills: ReadonlyArray<SkillRef>): SkillMetadataLine[] {
|
|
45
|
+
return skills.map((s) => ({
|
|
46
|
+
id: s.id,
|
|
47
|
+
name: s.manifest.name,
|
|
48
|
+
description: s.manifest.description,
|
|
49
|
+
}));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Frame the level-1 skill metadata as an inert prompt block. ONLY name+description appear — the
|
|
54
|
+
* body and bundled files are absent until activation/execution (FR-005, NFR-002). Returns "" when
|
|
55
|
+
* no skills are enabled so a baseline render adds nothing to the prompt (FR-002, acceptance #1).
|
|
56
|
+
*/
|
|
57
|
+
export function frameSkillsForPrompt(skills: ReadonlyArray<SkillRef>): string {
|
|
58
|
+
if (skills.length === 0) return "";
|
|
59
|
+
const lines = skillMetadata(skills).map(
|
|
60
|
+
// `id`/`name`/`description` come from a SKILL.md manifest (model-influenced bytes), so any line
|
|
61
|
+
// break or `[ ]` is neutralized — a crafted description can't forge a new `[skill …]` envelope
|
|
62
|
+
// or inject a directive line into the prompt block (defense-in-depth, §16b.1; mirrors safety.ts).
|
|
63
|
+
(m) => ` [skill ${neutralize(m.id)}] ${neutralize(m.name)}: ${neutralize(m.description)}`,
|
|
64
|
+
);
|
|
65
|
+
return [
|
|
66
|
+
"<available-skills>",
|
|
67
|
+
"These SKILLs may help. Only their name+description are shown; request a skill by name to load",
|
|
68
|
+
"its full instructions (this is progressive disclosure — the body is NOT loaded yet).",
|
|
69
|
+
...lines,
|
|
70
|
+
"</available-skills>",
|
|
71
|
+
].join("\n");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Neutralize the characters in a skill-metadata field that could forge a `[skill …]` envelope or a
|
|
76
|
+
* line break (`[`, `]`, `\n`, `\r`, `\\`) when interpolated into the prompt block. A backslash-escape
|
|
77
|
+
* keeps the value human-readable; NO-OP for normal names/descriptions (mirrors safety.ts framing).
|
|
78
|
+
*/
|
|
79
|
+
function neutralize(value: string): string {
|
|
80
|
+
return value.replace(/[\\[\]\r\n]/g, (c) => (c === "\n" ? "\\n" : c === "\r" ? "\\r" : `\\${c}`));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** A degrade outcome — a gate denial that proceeds without the capability, attributably (FR-021). */
|
|
84
|
+
export interface SkillGateDenied {
|
|
85
|
+
readonly ok: false;
|
|
86
|
+
readonly reason: string;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Activation succeeded — the level-2 body is now available (the loop may feed it in). */
|
|
90
|
+
export interface SkillActivated {
|
|
91
|
+
readonly ok: true;
|
|
92
|
+
readonly id: string;
|
|
93
|
+
readonly manifest: SkillManifest;
|
|
94
|
+
readonly body: SkillBody;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export type ActivateResult = SkillActivated | SkillGateDenied;
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Level-2 ACTIVATION: load the full `SKILL.md` body for a matched skill, AFTER clearing the §58
|
|
101
|
+
* gate (`kind: 'skill', level: 2`). A denial DEGRADES — it returns `{ ok: false, reason }` so the
|
|
102
|
+
* render proceeds without the skill rather than crashing or bypassing the gate (FR-014, FR-021).
|
|
103
|
+
*/
|
|
104
|
+
export async function activateSkill(
|
|
105
|
+
skill: SkillRef,
|
|
106
|
+
gate: PolicyGateHook,
|
|
107
|
+
): Promise<ActivateResult> {
|
|
108
|
+
const decision = await gate.check({ kind: "skill", skill: skill.id, level: 2 });
|
|
109
|
+
if (!decision.allow) {
|
|
110
|
+
return { ok: false, reason: gateReason(decision, `skill "${skill.id}" activation denied`) };
|
|
111
|
+
}
|
|
112
|
+
const body = await skill.loadBody();
|
|
113
|
+
return { ok: true, id: skill.id, manifest: skill.manifest, body };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Execution succeeded — the bundled resource's bytes (NOT its source-in-context) are available. */
|
|
117
|
+
export interface BundledOpened {
|
|
118
|
+
readonly ok: true;
|
|
119
|
+
readonly path: string;
|
|
120
|
+
readonly kind: BundledResource["kind"];
|
|
121
|
+
readonly bytes: Uint8Array;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export type OpenBundledResult = BundledOpened | SkillGateDenied;
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Level-3 EXECUTION: read or run a bundled resource ON DEMAND, AFTER clearing the §58 gate
|
|
128
|
+
* (`kind: 'skill', level: 3`). The script's SOURCE is never loaded into the model context — the
|
|
129
|
+
* runner calls `resource.open()` only after the gate clears, holding bundled-script execution to
|
|
130
|
+
* the SAME banned-pattern/allowlist/budget gate as generated code (§58, §16b.1, FR-014). A denial
|
|
131
|
+
* DEGRADES (FR-021).
|
|
132
|
+
*/
|
|
133
|
+
export async function openBundled(
|
|
134
|
+
skillId: string,
|
|
135
|
+
resource: BundledResource,
|
|
136
|
+
gate: PolicyGateHook,
|
|
137
|
+
): Promise<OpenBundledResult> {
|
|
138
|
+
const decision = await gate.check({ kind: "skill", skill: skillId, level: 3 });
|
|
139
|
+
if (!decision.allow) {
|
|
140
|
+
return {
|
|
141
|
+
ok: false,
|
|
142
|
+
reason: gateReason(decision, `skill "${skillId}" bundled "${resource.path}" execution denied`),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
// The gate cleared: only NOW is the resource read/executed (its source never entered context).
|
|
146
|
+
const bytes = await resource.open();
|
|
147
|
+
return { ok: true, path: resource.path, kind: resource.kind, bytes };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** A truthy, attributable reason for a degrade (FR-021, NFR-004). */
|
|
151
|
+
function gateReason(decision: GateDecision, fallback: string): string {
|
|
152
|
+
return decision.reason && decision.reason.length > 0 ? decision.reason : fallback;
|
|
153
|
+
}
|
package/src/stream.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @cloc/provider-ai-sdk · stream.ts — the partial-object stream adapter (FR-011, NFR-007, §60).
|
|
3
|
+
*
|
|
4
|
+
* Synthesized regions stream in as the model produces them so the projector renders KNOWN FIELDS
|
|
5
|
+
* of a half-arrived plan without blocking (low TTFB, §60). This maps the AI SDK v6 partial-object
|
|
6
|
+
* stream → the adapter's {@link StreamChunk} (and the core's `Delta`): `partial-object` deltas,
|
|
7
|
+
* interleaved `tool-call`/`tool-result` loop events, ending in a terminal `final` validated Output
|
|
8
|
+
* — or an `error`. On error/exhaustion ANY already-streamed partial is INVALIDATED: the stream
|
|
9
|
+
* ends WITHOUT a `final` the runtime could mistake for completion (edge case, data-model §4).
|
|
10
|
+
*
|
|
11
|
+
* AI SDK v6 surface (verified, not stale memory): `streamText({ ..., output: Output.object(...) })`
|
|
12
|
+
* exposes `result.experimental_partialOutputStream` (async-iterable partial objects) and
|
|
13
|
+
* `result.fullStream` (tool-call/tool-result/finish parts). `result.experimental_output` resolves
|
|
14
|
+
* the final typed object. We do not assume property names beyond these v6 docs shapes.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { partial as coreDelta, final as coreFinal, makeOutput } from "@cloc/core";
|
|
18
|
+
import type { Delta, Output, StandardSchema } from "./tokens.js";
|
|
19
|
+
import type { StructuredOutput } from "./output.js";
|
|
20
|
+
import type { LoopEvent } from "./tool-loop.js";
|
|
21
|
+
|
|
22
|
+
/** Adapter stream chunk (contracts/agent-provider.ts StreamChunk). `TPlan` is the kit plan type. */
|
|
23
|
+
export type StreamChunk<TPlan = unknown> =
|
|
24
|
+
| { kind: "partial-object"; value: Partial<TPlan> }
|
|
25
|
+
| { kind: "tool-call"; tool: string; args: unknown }
|
|
26
|
+
| { kind: "tool-result"; tool: string; result: unknown }
|
|
27
|
+
| { kind: "final"; output: StructuredOutput<TPlan> }
|
|
28
|
+
| { kind: "error"; error: { code: string; message: string; fatal: boolean } };
|
|
29
|
+
|
|
30
|
+
/** Map a loop event to its stream chunk. */
|
|
31
|
+
export function loopEventToChunk<TPlan>(event: LoopEvent): StreamChunk<TPlan> {
|
|
32
|
+
return event.kind === "tool-call"
|
|
33
|
+
? { kind: "tool-call", tool: event.tool, args: event.args }
|
|
34
|
+
: { kind: "tool-result", tool: event.tool, result: event.result };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Wrap a half-arrived plan snapshot as a `partial-object` chunk (parse-and-heal input, §60). */
|
|
38
|
+
export function partialChunk<TPlan>(value: Partial<TPlan>): StreamChunk<TPlan> {
|
|
39
|
+
return { kind: "partial-object", value };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Lower an adapter {@link StreamChunk} to the core's vendor-free `Delta` (the surface
|
|
44
|
+
* `AgentProvider.stream` actually yields). `partial-object` and the loop events become a
|
|
45
|
+
* `{ kind: "partial", patch }`; the terminal validated Output becomes `{ kind: "final", output }`.
|
|
46
|
+
* On `error` we DO NOT emit a `final` — the agent throws/rejects the iterator instead so the
|
|
47
|
+
* runtime never mistakes a failed stream for completion (output.ts §3 rule; core Delta contract).
|
|
48
|
+
*
|
|
49
|
+
* `schema` brands the final payload's `Output` (the core requires the validated schema on it).
|
|
50
|
+
*/
|
|
51
|
+
export function chunkToDelta<TPlan>(
|
|
52
|
+
chunk: StreamChunk<TPlan>,
|
|
53
|
+
schema: StandardSchema<unknown>,
|
|
54
|
+
): Delta | undefined {
|
|
55
|
+
switch (chunk.kind) {
|
|
56
|
+
case "partial-object":
|
|
57
|
+
return coreDelta({ partialPlan: chunk.value });
|
|
58
|
+
case "tool-call":
|
|
59
|
+
return coreDelta({ toolCall: { tool: chunk.tool, args: chunk.args } });
|
|
60
|
+
case "tool-result":
|
|
61
|
+
return coreDelta({ toolResult: { tool: chunk.tool, result: chunk.result } });
|
|
62
|
+
case "final":
|
|
63
|
+
return coreFinal(toCoreOutput(chunk.output, schema));
|
|
64
|
+
case "error":
|
|
65
|
+
// The agent surfaces errors by rejecting the iterator (never a fabricated `final`).
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Brand a validated {@link StructuredOutput} as the core's `Output` (a typed UI plan, never markup). */
|
|
71
|
+
export function toCoreOutput<TPlan>(
|
|
72
|
+
output: StructuredOutput<TPlan>,
|
|
73
|
+
schema: StandardSchema<unknown>,
|
|
74
|
+
): Output {
|
|
75
|
+
return makeOutput({
|
|
76
|
+
// The kit plan IS the UI-plan / IR node tree the RenderEngine later lowers (output.ts §3).
|
|
77
|
+
plan: output.plan as never,
|
|
78
|
+
schema,
|
|
79
|
+
});
|
|
80
|
+
}
|