@konneal/engine 0.2.33 → 0.2.34
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/dist/apicalls.d.ts +72 -0
- package/dist/ask-BDD5TFNC.js +12 -0
- package/dist/{chunk-JGKSUSF5.js → chunk-4GJGBGJK.js} +3 -3
- package/dist/{chunk-NHHQAGBW.js → chunk-DADOI4Q6.js} +42 -6
- package/dist/{chunk-WSLADX7O.js → chunk-MHA2OFWE.js} +304 -10
- package/dist/{chunk-5LSEBH3J.js → chunk-MNNE7OIM.js} +4 -4
- package/dist/{chunk-2RKYO3OC.js → chunk-R2V3X6SQ.js} +1 -1
- package/dist/{chunk-OMXAE27N.js → chunk-V46XM2GU.js} +4 -0
- package/dist/config.d.ts +1 -0
- package/dist/context.d.ts +37 -0
- package/dist/drafts.d.ts +5 -0
- package/dist/env.d.ts +2 -0
- package/dist/requestScope.d.ts +10 -2
- package/dist/search-EADF54TY.js +12 -0
- package/dist/worker_mcp/src/index.js +2 -2
- package/dist/worker_public/src/config.js +1 -1
- package/dist/worker_public/src/index.js +7 -7
- package/dist/worker_public/src/requestScope.js +2 -2
- package/docs/API.md +83 -0
- package/package.json +1 -1
- package/workers/worker_public/src/apicalls.ts +447 -0
- package/workers/worker_public/src/ask.ts +56 -6
- package/workers/worker_public/src/config.ts +4 -0
- package/workers/worker_public/src/context.ts +89 -4
- package/workers/worker_public/src/drafts.ts +10 -0
- package/workers/worker_public/src/env.ts +5 -1
- package/workers/worker_public/src/ports/cloudflare/adapters.ts +8 -0
- package/workers/worker_public/src/requestScope.ts +9 -3
- package/dist/ask-GU554Y45.js +0 -12
- package/dist/search-LGDQR43R.js +0 -12
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
// The api_call draft act (TODO.ai-platform/09) — the operations
|
|
2
|
+
// assistant's proposal grammar. One platform operation, prepared as a
|
|
3
|
+
// draft; the panel pre-flights it against the platform's assembled
|
|
4
|
+
// specification (unknown method+path pairs refuse there), the act class
|
|
5
|
+
// decides the arc (preference rides the standing grant's seam; record
|
|
6
|
+
// confirms per act and executes as the user), and the platform's own
|
|
7
|
+
// gates judge the write. This module, like drafts.ts, is STRUCTURALLY
|
|
8
|
+
// write-free: it reads the operation directory (D1) and calls the
|
|
9
|
+
// pick model; there is no fetch to the platform here.
|
|
10
|
+
//
|
|
11
|
+
// The invariants, and how this module keeps them:
|
|
12
|
+
//
|
|
13
|
+
// - THE DIRECTORY IS THE CLOSED WORLD. A draft's call names an
|
|
14
|
+
// operation the deployment's operation directory (the D1 `api_ops`
|
|
15
|
+
// table, loaded from the publisher's OpenAPI plane at ingest)
|
|
16
|
+
// actually carries — resolved by operation id AFTER the model
|
|
17
|
+
// proposes. An id the directory does not hold is no draft, ever;
|
|
18
|
+
// the model cannot invent a path because it never writes one.
|
|
19
|
+
//
|
|
20
|
+
// - THE MACHINE FACET BOUNDS THE MACHINE ACTS. An entity-context ask
|
|
21
|
+
// whose machine facet offers acts may propose only from that set
|
|
22
|
+
// (machineOffers); an empty set is the honest "the user can read
|
|
23
|
+
// but not act" — the answer says so and no draft rides. The facet
|
|
24
|
+
// is advisory; the platform re-judges authoritatively.
|
|
25
|
+
//
|
|
26
|
+
// - THE NEVER-OFFER LIST IS PROFILE DATA. The publisher declares the
|
|
27
|
+
// operations the assistant never proposes (the auth family — signout
|
|
28
|
+
// is the user's own, forever — and the grant's own mint/revoke:
|
|
29
|
+
// delegation is granted by the user's hand on the settings page,
|
|
30
|
+
// never by a chat message). The list filters the pick prompt AND
|
|
31
|
+
// re-checks the picked operation (belt and braces).
|
|
32
|
+
//
|
|
33
|
+
// - BODIES TRACE. A preference draft's body carries only values the
|
|
34
|
+
// user stated (drafts.ts's traceability primitive); an untraceable
|
|
35
|
+
// body refuses honestly instead of guessing settings.
|
|
36
|
+
//
|
|
37
|
+
// - TITLES SAY WHAT THE ACT DOES. "Save the filter", never
|
|
38
|
+
// "Execute PUT /api/…" — a title that leaks the wire shape is
|
|
39
|
+
// rejected and falls back to the operation's own summary.
|
|
40
|
+
|
|
41
|
+
import { P } from "./profile.ts";
|
|
42
|
+
import { machineOffers, type DeclaredContext, type MachineContext } from "./context.ts";
|
|
43
|
+
import { valueTracesToUser } from "./drafts.ts";
|
|
44
|
+
import { portModelRunner, portStore } from "./env.ts";
|
|
45
|
+
import type { ModelRunner } from "./ports/model.ts";
|
|
46
|
+
import type { StoreQuery } from "./ports/store.ts";
|
|
47
|
+
|
|
48
|
+
// ── the wire shape (the estate contract — the panel and the platform
|
|
49
|
+
// mirror it; the deployment's docs carry the reference) ────────────
|
|
50
|
+
|
|
51
|
+
export interface ApiCall {
|
|
52
|
+
method: "POST" | "PUT" | "PATCH" | "DELETE" | "GET";
|
|
53
|
+
path: string;
|
|
54
|
+
body?: unknown;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface ApiCallDraft {
|
|
58
|
+
kind: "draft";
|
|
59
|
+
act: "api_call";
|
|
60
|
+
version: 1;
|
|
61
|
+
title: string;
|
|
62
|
+
prepared_at: string;
|
|
63
|
+
/** ALWAYS true — the card's tap (or the standing grant, for the
|
|
64
|
+
* preference family) is the only authority that executes. */
|
|
65
|
+
requires_confirmation: true;
|
|
66
|
+
call: ApiCall;
|
|
67
|
+
notes?: string[];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export type ApiCallRefusalReason =
|
|
71
|
+
| "sign_in_required"
|
|
72
|
+
| "machine_read_only"
|
|
73
|
+
| "no_matching_act"
|
|
74
|
+
| "entity_unresolved"
|
|
75
|
+
| "operation_unknown"
|
|
76
|
+
| "operation_never_offered"
|
|
77
|
+
| "body_untraced"
|
|
78
|
+
| "pick_failed";
|
|
79
|
+
|
|
80
|
+
export type ApiCallVerdict =
|
|
81
|
+
| { status: "draft"; draft: ApiCallDraft; answer: string }
|
|
82
|
+
| { status: "refused"; reason: ApiCallRefusalReason; answer: string };
|
|
83
|
+
|
|
84
|
+
// ── the profile's acts facet (publisher data — the engine ships none) ─
|
|
85
|
+
|
|
86
|
+
interface ActsProfile {
|
|
87
|
+
/** the generic entity write the machine acts ride (path carries the
|
|
88
|
+
* {store}/{id} template exactly as the plane declares it) */
|
|
89
|
+
entity_write?: { method: string; path: string };
|
|
90
|
+
/** the entity pages' route patterns → the store the entity lives in */
|
|
91
|
+
entity_routes?: Array<{ pattern: string; store: string }>;
|
|
92
|
+
/** path prefixes the assistant never proposes (the auth family, the
|
|
93
|
+
* grant's own mint/revoke) */
|
|
94
|
+
never_offer?: string[];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function actsProfile(): ActsProfile | null {
|
|
98
|
+
const acts = (P() as any).publisher?.acts;
|
|
99
|
+
return acts && typeof acts === "object" ? acts : null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** A target the assistant never proposes: the never-offer prefixes match
|
|
103
|
+
* the operation's declared (templated) path. */
|
|
104
|
+
export function neverOffered(path: string): boolean {
|
|
105
|
+
const prefixes = actsProfile()?.never_offer ?? [];
|
|
106
|
+
return prefixes.some((p) => typeof p === "string" && path.startsWith(p));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ── the operation directory (D1, loaded from the plane at ingest) ────
|
|
110
|
+
|
|
111
|
+
export interface ApiOp {
|
|
112
|
+
operation_id: string;
|
|
113
|
+
method: string;
|
|
114
|
+
path: string;
|
|
115
|
+
act_class: string | null;
|
|
116
|
+
summary: string | null;
|
|
117
|
+
tag: string | null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function operationById(store: StoreQuery, id: string): Promise<ApiOp | null> {
|
|
121
|
+
try {
|
|
122
|
+
return (await store.prepare("SELECT operation_id, method, path, act_class, summary, tag FROM api_ops WHERE operation_id = ?1").bind(id).first()) as ApiOp | null;
|
|
123
|
+
} catch {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function operationByMethodPath(store: StoreQuery, method: string, path: string): Promise<ApiOp | null> {
|
|
129
|
+
try {
|
|
130
|
+
return (await store.prepare("SELECT operation_id, method, path, act_class, summary, tag FROM api_ops WHERE method = ?1 AND path = ?2").bind(method.toLowerCase(), path).first()) as ApiOp | null;
|
|
131
|
+
} catch {
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** The preference family (the standing grant's closed world), for the
|
|
137
|
+
* pick prompt — bounded; a deployment whose family outgrows the cap
|
|
138
|
+
* truncates honestly (the directory read is ordered, never random). */
|
|
139
|
+
async function preferenceOps(store: StoreQuery): Promise<ApiOp[]> {
|
|
140
|
+
try {
|
|
141
|
+
const rows = await store.prepare("SELECT operation_id, method, path, act_class, summary, tag FROM api_ops WHERE act_class = 'preference' ORDER BY operation_id LIMIT 32").bind().all();
|
|
142
|
+
return ((rows as any).results ?? []) as ApiOp[];
|
|
143
|
+
} catch {
|
|
144
|
+
return [];
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ── the entity resolution (the route pattern table is profile data) ──
|
|
149
|
+
|
|
150
|
+
/** Parse the declared context's route against the profile's entity-route
|
|
151
|
+
* patterns (`/a/b/:id` segments; the LAST parameter captures the entity
|
|
152
|
+
* id). Returns the store + id the entity write addresses, or null — a
|
|
153
|
+
* route the table does not cover is an honest unresolved, never a guess. */
|
|
154
|
+
export function resolveEntityRoute(route: string | undefined): { store: string; id: string } | null {
|
|
155
|
+
const patterns = actsProfile()?.entity_routes ?? [];
|
|
156
|
+
if (!route) return null;
|
|
157
|
+
const segs = route.split("?")[0]!.split("/").filter(Boolean);
|
|
158
|
+
for (const p of patterns) {
|
|
159
|
+
const psegs = String(p.pattern ?? "").split("/").filter(Boolean);
|
|
160
|
+
if (psegs.length !== segs.length) continue;
|
|
161
|
+
let id: string | null = null;
|
|
162
|
+
let ok = true;
|
|
163
|
+
for (let i = 0; i < psegs.length; i++) {
|
|
164
|
+
const ps = psegs[i]!;
|
|
165
|
+
if (ps.startsWith(":")) {
|
|
166
|
+
id = decodeURIComponent(segs[i]!);
|
|
167
|
+
} else if (ps !== segs[i]) {
|
|
168
|
+
ok = false;
|
|
169
|
+
break;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
// the id is a routing token, bounded and charset-checked — it lands
|
|
173
|
+
// in a URL path, never in prose
|
|
174
|
+
if (ok && id && id.length <= 120 && /^[A-Za-z0-9._~-]+$/.test(id)) return { store: String(p.store), id };
|
|
175
|
+
}
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ── the intent detection (conservative — a false positive would offer
|
|
180
|
+
// an act the user never asked for) ─────────────────────────────────
|
|
181
|
+
|
|
182
|
+
/** The machine-act intent: an entity declaration with a machine facet,
|
|
183
|
+
* plus the user's own act verb aimed at the entity. */
|
|
184
|
+
const MACHINE_INTENT_RE =
|
|
185
|
+
/\b(accept|reject|submit|resubmit|withdraw|suspend|reinstate|renew|revise|transfer|issue|register|log|record|dispatch|complete|sign|approve|verify)\b[\s\S]{0,40}\b(this|that|the|it)\b|\b(this|that|the|it)\b[\s\S]{0,40}\b(accept|reject|submit|resubmit|withdraw|suspend|reinstate|renew|revise|transfer|issue|register|log|dispatch|complete|sign|approve|verify)\b/i;
|
|
186
|
+
|
|
187
|
+
/** The preference intent: the user's own settings vocabulary. */
|
|
188
|
+
const PREFERENCE_INTENT_RE =
|
|
189
|
+
/\b(mute|unmute|unsubscribe|subscribe|notification prefs?|notification rules?|email prefs?|email preferences|saved? filters?|save (?:this|the|that) filter|notify me|stop notifying|inbox)\b/i;
|
|
190
|
+
|
|
191
|
+
export type ApiCallIntent = "machine_act" | "preference";
|
|
192
|
+
|
|
193
|
+
/** A question ABOUT a concept is never an act request ("What is a saved
|
|
194
|
+
* filter?" asks for knowledge; "save this filter" acts). Conservative
|
|
195
|
+
* by design: the question openers steer to the corpus path. */
|
|
196
|
+
const QUESTION_OPENER_RE = /^\s*(?:what|how|why|where|when|which|who|whose|explain|describe|define|is|are|does|do)\b/i;
|
|
197
|
+
|
|
198
|
+
/** Which api_call family the ask aims at, or null. The machine facet
|
|
199
|
+
* decides the machine family (no facet = no machine act, whatever the
|
|
200
|
+
* verbs); the preference family needs no facet. */
|
|
201
|
+
export function detectApiCallIntent(query: string, declared: DeclaredContext | null): ApiCallIntent | null {
|
|
202
|
+
if (QUESTION_OPENER_RE.test(query)) return null;
|
|
203
|
+
if (declared?.kind === "entity" && declared.machine && MACHINE_INTENT_RE.test(query)) return "machine_act";
|
|
204
|
+
if (PREFERENCE_INTENT_RE.test(query)) return "preference";
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// ── the pick (the model proposes; the directory and the facet dispose) ─
|
|
209
|
+
|
|
210
|
+
interface Pick {
|
|
211
|
+
/** machine family: the offered action id; preference family: the
|
|
212
|
+
* operation id — null is the honest "nothing here matches" */
|
|
213
|
+
target: string | null;
|
|
214
|
+
title?: string;
|
|
215
|
+
body?: unknown;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function pickPrompt(kind: ApiCallIntent, candidates: string[]): { system: string; max: number } {
|
|
219
|
+
if (kind === "machine_act") {
|
|
220
|
+
return {
|
|
221
|
+
system: `You map the user's request to one machine act from the OFFERED list, or to none.
|
|
222
|
+
|
|
223
|
+
Rules:
|
|
224
|
+
- Output ONLY a JSON object — no prose, no code fence: {"target": "<action id or null>", "title": "<what the act does, plain words>"}.
|
|
225
|
+
- The target must be one of the offered action ids, copied exactly, or null when the request matches none.
|
|
226
|
+
- The title names what the act DOES for the user ("Accept the application"), never the wire (no method, no path, no "/api/").
|
|
227
|
+
- Never invent an act that is not listed.
|
|
228
|
+
|
|
229
|
+
Offered acts (action id — lands the entity on):
|
|
230
|
+
${candidates.join("\n")}`,
|
|
231
|
+
max: 300,
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
return {
|
|
235
|
+
system: `You map the user's request to one operation from the PREFERENCE list (the user's own settings), or to none.
|
|
236
|
+
|
|
237
|
+
Rules:
|
|
238
|
+
- Output ONLY a JSON object — no prose, no code fence: {"target": "<operation id or null>", "title": "<what the act does, plain words>", "body": {...}}.
|
|
239
|
+
- The target must be one of the listed operation ids, copied exactly, or null when the request matches none.
|
|
240
|
+
- "body" carries only values the user stated in their own words; omit it when the request names no values.
|
|
241
|
+
- The title names what the act DOES for the user ("Save the filter", "Mute these emails"), never the wire (no method, no path, no "/api/").
|
|
242
|
+
- Never invent an operation that is not listed.
|
|
243
|
+
|
|
244
|
+
Preference operations (id — method path — summary):
|
|
245
|
+
${candidates.join("\n")}`,
|
|
246
|
+
max: 800,
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async function pickTarget(runner: ModelRunner, model: string, kind: ApiCallIntent, candidates: string[], userTurns: string[]): Promise<Pick | null> {
|
|
251
|
+
const { system, max } = pickPrompt(kind, candidates);
|
|
252
|
+
const transcript = userTurns.map((t, i) => `${i + 1}. ${t}`).join("\n").slice(0, 8000);
|
|
253
|
+
try {
|
|
254
|
+
const res = await runner.run({
|
|
255
|
+
model,
|
|
256
|
+
messages: [
|
|
257
|
+
{ role: "system", content: system },
|
|
258
|
+
{ role: "user", content: `The user's messages, oldest first:\n${transcript}` },
|
|
259
|
+
],
|
|
260
|
+
maxTokens: max,
|
|
261
|
+
effort: "low",
|
|
262
|
+
temperature: 0.1,
|
|
263
|
+
});
|
|
264
|
+
const text = res.text;
|
|
265
|
+
if (typeof text !== "string") return null;
|
|
266
|
+
const start = text.indexOf("{");
|
|
267
|
+
const end = text.lastIndexOf("}");
|
|
268
|
+
if (start < 0 || end <= start) return null;
|
|
269
|
+
const parsed = JSON.parse(text.slice(start, end + 1));
|
|
270
|
+
if (!parsed || typeof parsed !== "object") return null;
|
|
271
|
+
return {
|
|
272
|
+
target: typeof parsed.target === "string" && parsed.target.trim() ? parsed.target.trim().slice(0, 120) : null,
|
|
273
|
+
title: typeof parsed.title === "string" && parsed.title.trim() ? parsed.title.trim().slice(0, 120) : undefined,
|
|
274
|
+
body: parsed.body && typeof parsed.body === "object" ? parsed.body : undefined,
|
|
275
|
+
};
|
|
276
|
+
} catch {
|
|
277
|
+
return null;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** The title guard: a title that leaks the wire shape is no title. */
|
|
282
|
+
function honestTitle(proposed: string | undefined, fallback: string): string {
|
|
283
|
+
const t = (proposed ?? "").trim();
|
|
284
|
+
if (!t) return fallback;
|
|
285
|
+
if (/\b(GET|POST|PUT|PATCH|DELETE)\b/.test(t) || t.includes("/api/") || t.includes("{")) return fallback;
|
|
286
|
+
return t.slice(0, 120);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** The body guard: every string value traces to the user's own words
|
|
290
|
+
* (the never-invents doctrine, drafts.ts's primitive). Scalars the user
|
|
291
|
+
* toggles (booleans, numbers) pass — they state no fact. */
|
|
292
|
+
export function bodyTraces(body: unknown, userTurns: string[]): boolean {
|
|
293
|
+
if (body == null) return true;
|
|
294
|
+
if (typeof body === "boolean" || typeof body === "number") return true;
|
|
295
|
+
if (typeof body === "string") return valueTracesToUser(body, userTurns);
|
|
296
|
+
if (Array.isArray(body)) return body.every((v) => bodyTraces(v, userTurns));
|
|
297
|
+
if (typeof body === "object") return Object.values(body as Record<string, unknown>).every((v) => bodyTraces(v, userTurns));
|
|
298
|
+
return false;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// ── the composition ──────────────────────────────────────────────────
|
|
302
|
+
|
|
303
|
+
export interface ApiCallPrepareOpts {
|
|
304
|
+
intent: ApiCallIntent;
|
|
305
|
+
query: string;
|
|
306
|
+
history: Array<{ role: string; content: string }>;
|
|
307
|
+
member: { sub: string } | null;
|
|
308
|
+
declared: DeclaredContext | null;
|
|
309
|
+
/** the intent-mapping model (the profile's pick — the operations
|
|
310
|
+
* deployment runs the stronger reasoning tier here) */
|
|
311
|
+
model: string;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function refusal(reason: ApiCallRefusalReason, answer: string): ApiCallVerdict {
|
|
315
|
+
return { status: "refused", reason, answer };
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
export async function prepareApiCall(env: any, opts: ApiCallPrepareOpts): Promise<ApiCallVerdict> {
|
|
319
|
+
if (!opts.member) {
|
|
320
|
+
return refusal(
|
|
321
|
+
"sign_in_required",
|
|
322
|
+
"Preparing an act starts from your own account — sign in and ask again. " +
|
|
323
|
+
"The draft would still be yours alone: the card asks for your confirmation, and only your own tap executes it — I never hold a write credential.",
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
const acts = actsProfile();
|
|
327
|
+
const store = portStore(env);
|
|
328
|
+
const runner = portModelRunner(env);
|
|
329
|
+
const userTurns = [...opts.history.filter((h) => h.role === "user").map((h) => h.content), opts.query];
|
|
330
|
+
|
|
331
|
+
// ── the machine family: the offered set bounds the proposal ──
|
|
332
|
+
if (opts.intent === "machine_act") {
|
|
333
|
+
const machine = opts.declared?.machine as MachineContext | undefined;
|
|
334
|
+
const label = opts.declared?.label || "this record";
|
|
335
|
+
if (!machine) {
|
|
336
|
+
return refusal("no_matching_act", "This page did not declare the record's lifecycle state, so I cannot propose an act on it honestly. Open the record itself and ask again, or act from the platform's own controls — they always show exactly what your role may do.");
|
|
337
|
+
}
|
|
338
|
+
if (machine.acts.length === 0) {
|
|
339
|
+
// the honest "read but not act": say so, propose nothing
|
|
340
|
+
return refusal(
|
|
341
|
+
"machine_read_only",
|
|
342
|
+
`The record's lifecycle is at ${machine.state}, and the machine offers your role no acts there — you can read this record but not act on it. ` +
|
|
343
|
+
"Nothing was drafted. If that looks wrong, an officer of your organization can check the role your account carries.",
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
const entity = resolveEntityRoute(opts.declared?.route);
|
|
347
|
+
if (!entity) {
|
|
348
|
+
return refusal("entity_unresolved", "I cannot tell which record the act would land on from this page, so nothing was drafted. The platform's own controls on the record always act on the right one.");
|
|
349
|
+
}
|
|
350
|
+
const candidates = machine.acts.map((a) => `${a.action} — lands on ${a.to}${a.guard ? ` (requires ${a.guard})` : ""}`);
|
|
351
|
+
const pick = await pickTarget(runner, opts.model, "machine_act", candidates, userTurns);
|
|
352
|
+
if (!pick) {
|
|
353
|
+
return refusal("pick_failed", "I could not map your request to an act reliably just now — nothing was drafted. Ask again in a moment, or act from the platform's own controls.");
|
|
354
|
+
}
|
|
355
|
+
if (!pick.target || !machineOffers(machine, pick.target)) {
|
|
356
|
+
const offered = machine.acts.map((a) => a.action).join(", ");
|
|
357
|
+
return refusal(
|
|
358
|
+
"no_matching_act",
|
|
359
|
+
`None of the acts the machine offers your role at ${machine.state} matches that request — the offered set is exactly: ${offered}. ` +
|
|
360
|
+
"I never propose an act outside it. Nothing was drafted.",
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
const act = machine.acts.find((a) => a.action === pick.target)!;
|
|
364
|
+
const write = acts?.entity_write;
|
|
365
|
+
if (!write?.method || !write?.path) {
|
|
366
|
+
return refusal("operation_unknown", "This deployment has not wired the entity write operation, so I cannot prepare the act here.");
|
|
367
|
+
}
|
|
368
|
+
// the directory is the closed world: the entity write must exist in
|
|
369
|
+
// the plane, and the never-offer list re-checks even it
|
|
370
|
+
const op = await operationByMethodPath(store, write.method, write.path);
|
|
371
|
+
if (!op) {
|
|
372
|
+
return refusal("operation_unknown", "The act's operation is not in this deployment's API directory, so I cannot prepare it honestly — nothing was drafted.");
|
|
373
|
+
}
|
|
374
|
+
if (neverOffered(op.path)) {
|
|
375
|
+
return refusal("operation_never_offered", "That act is not one the assistant may propose — nothing was drafted.");
|
|
376
|
+
}
|
|
377
|
+
const path = write.path.replace("{store}", entity.store).replace("{id}", entity.id);
|
|
378
|
+
const title = honestTitle(pick.title, `${act.action} — ${label}`.slice(0, 120));
|
|
379
|
+
const notes = [
|
|
380
|
+
`The machine act ${act.action} moves the record from ${machine.state} to ${act.to}; the platform re-judges the act against the machine and your role when you confirm.`,
|
|
381
|
+
...(act.guard ? [`This act declares the ${act.guard} input — the confirmation card collects it; the draft carries none.`] : []),
|
|
382
|
+
];
|
|
383
|
+
const draft: ApiCallDraft = {
|
|
384
|
+
kind: "draft",
|
|
385
|
+
act: "api_call",
|
|
386
|
+
version: 1,
|
|
387
|
+
title,
|
|
388
|
+
prepared_at: new Date().toISOString(),
|
|
389
|
+
requires_confirmation: true,
|
|
390
|
+
call: { method: write.method as ApiCall["method"], path, body: { status: act.to } },
|
|
391
|
+
notes,
|
|
392
|
+
};
|
|
393
|
+
const answer =
|
|
394
|
+
`I've prepared the act "${title}" for ${label}: the machine act ${act.action}, from ${machine.state} to ${act.to}.\n\n` +
|
|
395
|
+
`The confirmation card carries it — a record act, so your own confirmation signs it and the platform's gates judge the write as you. ` +
|
|
396
|
+
`I never hold a write credential, and I never propose an act the machine does not offer your role.`;
|
|
397
|
+
return { status: "draft", draft, answer };
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// ── the preference family: the standing grant's closed world ──
|
|
401
|
+
const family = (await preferenceOps(store)).filter((o) => !neverOffered(o.path));
|
|
402
|
+
if (!family.length) {
|
|
403
|
+
return refusal("operation_unknown", "This deployment's API directory lists no preference operations, so I cannot prepare that here — nothing was drafted.");
|
|
404
|
+
}
|
|
405
|
+
const candidates = family.map((o) => `${o.operation_id} — ${o.method.toUpperCase()} ${o.path} — ${o.summary ?? ""}`.slice(0, 300));
|
|
406
|
+
const pick = await pickTarget(runner, opts.model, "preference", candidates, userTurns);
|
|
407
|
+
if (!pick) {
|
|
408
|
+
return refusal("pick_failed", "I could not map your request to a preference act reliably just now — nothing was drafted. Ask again in a moment, or change the setting on your account page directly.");
|
|
409
|
+
}
|
|
410
|
+
if (!pick.target) {
|
|
411
|
+
return refusal("no_matching_act", "None of the preference acts I may propose matches that request — nothing was drafted. Your account's settings page carries the full set.");
|
|
412
|
+
}
|
|
413
|
+
const op = await operationById(store, pick.target);
|
|
414
|
+
if (!op || op.act_class !== "preference") {
|
|
415
|
+
// the picked id resolves to nothing, or to a record act — either way
|
|
416
|
+
// the draft never rides (the model proposed outside the closed world)
|
|
417
|
+
return refusal("operation_unknown", "I cannot match that request to a preference act the platform declares — nothing was drafted.");
|
|
418
|
+
}
|
|
419
|
+
if (neverOffered(op.path)) {
|
|
420
|
+
return refusal("operation_never_offered", "That act is not one the assistant may propose — delegation and sign-in acts stay in your own hands on the settings page. Nothing was drafted.");
|
|
421
|
+
}
|
|
422
|
+
if (pick.body !== undefined && !bodyTraces(pick.body, userTurns)) {
|
|
423
|
+
return refusal(
|
|
424
|
+
"body_untraced",
|
|
425
|
+
"I can't prepare that change because the values I would set are not ones you stated — I never invent settings. " +
|
|
426
|
+
"Tell me exactly what to set, in your own words, and I'll prepare the draft.",
|
|
427
|
+
);
|
|
428
|
+
}
|
|
429
|
+
const title = honestTitle(pick.title, (op.summary ?? op.operation_id).slice(0, 120));
|
|
430
|
+
const draft: ApiCallDraft = {
|
|
431
|
+
kind: "draft",
|
|
432
|
+
act: "api_call",
|
|
433
|
+
version: 1,
|
|
434
|
+
title,
|
|
435
|
+
prepared_at: new Date().toISOString(),
|
|
436
|
+
requires_confirmation: true,
|
|
437
|
+
call: { method: op.method.toUpperCase() as ApiCall["method"], path: op.path, ...(pick.body !== undefined ? { body: pick.body } : {}) },
|
|
438
|
+
notes: [
|
|
439
|
+
"A preference act: with your standing grant the card executes it straight away; without one, your own tap on the card confirms it. The grant is yours on the settings page — granted or revoked by your hand, never by a message.",
|
|
440
|
+
],
|
|
441
|
+
};
|
|
442
|
+
const answer =
|
|
443
|
+
`I've prepared the act "${title}" — a preference act on your own settings.\n\n` +
|
|
444
|
+
"With your standing grant it executes under the grant's authority; without one, the card asks once and your tap confirms it. " +
|
|
445
|
+
"The platform re-checks the act against its own specification either way, and the grant never covers record acts.";
|
|
446
|
+
return { status: "draft", draft, answer };
|
|
447
|
+
}
|
|
@@ -26,8 +26,9 @@ import { evaluateConditionSets, quantitiesIn, type ConditionVerdict } from "./co
|
|
|
26
26
|
import { evaluateAggregation, type AggregationVerdict } from "./aggregation";
|
|
27
27
|
import { matchLicensedTopic, boundaryNoteText } from "./boundary";
|
|
28
28
|
import { detectDraftIntent, prepareDraft } from "./drafts";
|
|
29
|
+
import { detectApiCallIntent, prepareApiCall } from "./apicalls";
|
|
29
30
|
import { memoryNote } from "./memories";
|
|
30
|
-
import { entitlementScope, resolveRequestScope, requestSalt } from "./requestScope";
|
|
31
|
+
import { entitlementScope, resolveRequestScope, requestSalt, standardKeysFrom } from "./requestScope";
|
|
31
32
|
import { rawSessionToken } from "./session";
|
|
32
33
|
import { cacheKeyMaterial, corpusGen, exactCacheKey, freshRequested, semanticCacheKey } from "./answercache";
|
|
33
34
|
import type { Env } from "./env";
|
|
@@ -277,6 +278,13 @@ async function handleAsk(
|
|
|
277
278
|
// ask bypasses both answer caches (read AND write) exactly as a
|
|
278
279
|
// declared-context ask does.
|
|
279
280
|
const draftAct = P().publisher.features?.drafts ? detectDraftIntent(q.query) : null;
|
|
281
|
+
// The api_call draft (TODO.ai-platform/09): the operations deployment's
|
|
282
|
+
// proposal grammar — one platform operation, bounded by the affordance
|
|
283
|
+
// channel's machine facet (machine acts) or the preference family's
|
|
284
|
+
// closed world (the operation directory). Same cache posture as the
|
|
285
|
+
// prefill draft: the answer depends on the conversation and the
|
|
286
|
+
// account's standing, never on the query alone.
|
|
287
|
+
const apiCallIntent = !draftAct && P().publisher.features?.api_call_drafts ? detectApiCallIntent(q.query, declaredCtx) : null;
|
|
280
288
|
|
|
281
289
|
const member = tier === "member" ? await sessionFrom(req, env as any) : null;
|
|
282
290
|
// resolved before the quota check: the effort choice prices the ask
|
|
@@ -301,15 +309,20 @@ async function handleAsk(
|
|
|
301
309
|
// Members get federated retrieval (OIML + ISO/IEC) merged into the same
|
|
302
310
|
// pipeline via the service binding; rag-public never touches the
|
|
303
311
|
// internal index itself, and generation/rerank stay in ONE pipeline.
|
|
312
|
+
// The key-tier licensed federation (the LIVE_MEMBER_TOKEN retirement):
|
|
313
|
+
// an API-key caller with a VALIDATED entitlement set rides the same
|
|
314
|
+
// lane — the key is re-verified server-side by the internal worker and
|
|
315
|
+
// the entitlement set is the licensed scope the hard cut honors.
|
|
304
316
|
const service = env.INTERNAL_SERVICE;
|
|
305
317
|
const fedAuth = {
|
|
306
318
|
cookie: req.headers.get("cookie") ?? "",
|
|
307
319
|
authorization: req.headers.get("authorization") ?? "",
|
|
308
320
|
};
|
|
321
|
+
const keyLicensed = tier === "key" && standardKeysFrom(body).size > 0;
|
|
309
322
|
// Dataset scope + memory selection (MECE: the derivation lives in
|
|
310
323
|
// ./requestScope; this path only wires it). A request that explicitly
|
|
311
324
|
// disables every dataset is a user error.
|
|
312
|
-
const scope = resolveRequestScope(body, member);
|
|
325
|
+
const scope = resolveRequestScope(body, member, { keyWithEntitlements: keyLicensed });
|
|
313
326
|
if ("error" in scope) return err(400, "invalid_input", "datasets: at least one dataset must stay enabled");
|
|
314
327
|
const { corpora, narrowed, isoOn } = scope;
|
|
315
328
|
// The license entitlement set (TODO.external-refs/08): request-scoped,
|
|
@@ -327,7 +340,7 @@ async function handleAsk(
|
|
|
327
340
|
// per-request depth toggle (⚡ fast / 🧠 thorough): effort changes the
|
|
328
341
|
// answer, so it salts the cache with the selections above
|
|
329
342
|
const salt = requestSaltStr ? `${requestSaltStr}|effort:${effort}` : `effort:${effort}`;
|
|
330
|
-
const federate = member && service && isoOn
|
|
343
|
+
const federate = (member || keyLicensed) && service && isoOn
|
|
331
344
|
? (q2: string) => retrieveInternal(service, fedAuth, q2)
|
|
332
345
|
: undefined;
|
|
333
346
|
|
|
@@ -362,7 +375,7 @@ async function handleAsk(
|
|
|
362
375
|
// answer caches; corpus surgery bumps it (scripts/invalidate_answer_
|
|
363
376
|
// cache.py) and old-generation entries miss (oimlsmart/rag#72)
|
|
364
377
|
const gen = await corpusGen(env.CACHE);
|
|
365
|
-
const cached = fresh || contextual || declaredCtx || draftAct || userImage ? null : await cacheGet(env, gen, ns, q.query, q.lang, salt);
|
|
378
|
+
const cached = fresh || contextual || declaredCtx || draftAct || apiCallIntent || userImage ? null : await cacheGet(env, gen, ns, q.query, q.lang, salt);
|
|
366
379
|
const wantsStream = body?.stream === true || (tier === "anon" && body?.stream !== false);
|
|
367
380
|
|
|
368
381
|
if (cached) {
|
|
@@ -409,7 +422,7 @@ async function handleAsk(
|
|
|
409
422
|
// node's answer for another (observed run-to-run across the golden
|
|
410
423
|
// model legs). Node-scoped queries use the exact cache only.
|
|
411
424
|
const nodeScoped = !!modelNodeRefIn(q.query) || !!modelNodeRefIn(declaredCtx?.label);
|
|
412
|
-
if (!cached && !nodeScoped && !contextual && !declaredCtx && !draftAct && !q.lang && !userImage && !fresh) {
|
|
425
|
+
if (!cached && !nodeScoped && !contextual && !declaredCtx && !draftAct && !apiCallIntent && !q.lang && !userImage && !fresh) {
|
|
413
426
|
const wv0 = (await warmEmbed) ?? null;
|
|
414
427
|
if (wv0) {
|
|
415
428
|
const sc0 = await semanticCacheGet(env, gen, wv0, salt);
|
|
@@ -553,7 +566,7 @@ async function handleAsk(
|
|
|
553
566
|
// for standalone knowledge questions; contextual turns, declared-context
|
|
554
567
|
// asks and image asks always run live; fresh regenerates, bypassing
|
|
555
568
|
// this cache too)
|
|
556
|
-
if (understanding?.intent !== "conversational" && !nodeScoped && !contextual && !declaredCtx && !draftAct && !userImage && !fresh) {
|
|
569
|
+
if (understanding?.intent !== "conversational" && !nodeScoped && !contextual && !declaredCtx && !draftAct && !apiCallIntent && !userImage && !fresh) {
|
|
557
570
|
const warmVec = (await warmEmbed) ?? null;
|
|
558
571
|
if (warmVec) {
|
|
559
572
|
const sc = await semanticCacheGet(env, gen, warmVec, salt);
|
|
@@ -681,6 +694,43 @@ async function handleAsk(
|
|
|
681
694
|
return json({ answer: verdict.answer, citations, model, query_hash: queryHash, follow_ups: [], context_applied: draftCtxApplied, read: readAs(), ...(draftPayload ? { draft: draftPayload } : {}), quota, });
|
|
682
695
|
}
|
|
683
696
|
|
|
697
|
+
// ── The api_call draft (TODO.ai-platform/09) — the operations
|
|
698
|
+
// assistant's proposal grammar (apicalls.ts): one platform operation,
|
|
699
|
+
// prepared as a draft, bounded by the affordance channel's machine
|
|
700
|
+
// facet or the preference family's closed world. THE SERVICE NEVER
|
|
701
|
+
// WRITES here either: the draft rides the response's `draft` field;
|
|
702
|
+
// the panel pre-flights it against the platform's specification and
|
|
703
|
+
// the platform's own gates judge the execution. Branched where the
|
|
704
|
+
// prefill draft branches — the draft grounds in the conversation +
|
|
705
|
+
// the operation directory, not the corpus passages.
|
|
706
|
+
if (apiCallIntent) {
|
|
707
|
+
const callCtxApplied = declaredCtx ? appliedContext(declaredCtx, null) : NO_CONTEXT;
|
|
708
|
+
const queryHash = await sha256Hex(q.query);
|
|
709
|
+
const verdict = await prepareApiCall(env, {
|
|
710
|
+
intent: apiCallIntent,
|
|
711
|
+
query: q.query,
|
|
712
|
+
history: keptHistory,
|
|
713
|
+
member,
|
|
714
|
+
declared: declaredCtx,
|
|
715
|
+
model: roleModel(env, "acts"),
|
|
716
|
+
});
|
|
717
|
+
console.log("api_call draft:", apiCallIntent, "→", verdict.status === "draft" ? `draft (${verdict.draft.call.method} ${verdict.draft.call.path})` : `refused (${verdict.reason})`);
|
|
718
|
+
const draftPayload = verdict.status === "draft" ? verdict.draft : undefined;
|
|
719
|
+
telemetry(env, ctx, tier, "ask", model, true, verdict.answer.length, queryHash, q.lang, undefined, telemetryMeta());
|
|
720
|
+
if (wantsStream) {
|
|
721
|
+
return sseResponse(
|
|
722
|
+
[
|
|
723
|
+
...(readAs() ? [{ type: "read", read: readAs() }] : []),
|
|
724
|
+
{ type: "citations", citations: [], context_applied: callCtxApplied, ...(draftPayload ? { draft: draftPayload } : {}), quota, },
|
|
725
|
+
{ type: "token", v: verdict.answer },
|
|
726
|
+
{ type: "done", model, query_hash: queryHash, context_applied: callCtxApplied, read: readAs() },
|
|
727
|
+
],
|
|
728
|
+
corsHeaders(req),
|
|
729
|
+
);
|
|
730
|
+
}
|
|
731
|
+
return json({ answer: verdict.answer, citations: [], model, query_hash: queryHash, follow_ups: [], context_applied: callCtxApplied, read: readAs(), ...(draftPayload ? { draft: draftPayload } : {}), quota, });
|
|
732
|
+
}
|
|
733
|
+
|
|
684
734
|
// (declared before the retrieval try: the account block, the refusal
|
|
685
735
|
// gate, the prompt and the response all read them)
|
|
686
736
|
let liveRecords: LiveRecord[] | undefined;
|
|
@@ -9,6 +9,10 @@ export const MODELS = {
|
|
|
9
9
|
member: "@cf/zai-org/glm-5.3-flash",
|
|
10
10
|
// hot-path understanding/summarize stays on the cheap Qwen (cost-first lane)
|
|
11
11
|
understand: "@cf/qwen/qwen3-30b-a3b-fp8",
|
|
12
|
+
// the api_call act's intent-to-act mapping (TODO.ai-platform/09) — the
|
|
13
|
+
// operations deployment points ACTS_MODEL at the stronger reasoning
|
|
14
|
+
// tier; the floor is the understand lane
|
|
15
|
+
acts: "@cf/qwen/qwen3-30b-a3b-fp8",
|
|
12
16
|
// generation fallback when the answer model is unavailable
|
|
13
17
|
fallback: "@cf/qwen/qwen3-30b-a3b-fp8",
|
|
14
18
|
grader: "@cf/deepseek-ai/deepseek-v4-flash-0731", // CRAG grader + judges (unsuffixed slug was retired → silent 5018s)
|