@foldrun/core 0.4.0 → 0.6.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/README.md +7 -4
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/src/account.d.ts +45 -0
- package/dist/src/account.js +130 -0
- package/dist/src/api-tools.d.ts +8 -0
- package/dist/src/api-tools.js +22 -4
- package/dist/src/browser-pod.d.ts +148 -0
- package/dist/src/browser-pod.js +333 -0
- package/dist/src/catalog.js +2 -1
- package/dist/src/clock.d.ts +49 -0
- package/dist/src/clock.js +121 -0
- package/dist/src/completions.js +17 -8
- package/dist/src/confine.d.ts +22 -0
- package/dist/src/confine.js +218 -3
- package/dist/src/container.d.ts +9 -0
- package/dist/src/container.js +115 -5
- package/dist/src/context-tools.d.ts +1 -1
- package/dist/src/deploy.d.ts +33 -0
- package/dist/src/deploy.js +145 -6
- package/dist/src/egress.d.ts +7 -0
- package/dist/src/egress.js +6 -0
- package/dist/src/eol.d.ts +2 -0
- package/dist/src/eol.js +7 -0
- package/dist/src/evals.d.ts +5 -0
- package/dist/src/evals.js +9 -2
- package/dist/src/flow-lint.d.ts +8 -0
- package/dist/src/flow-lint.js +223 -1
- package/dist/src/flow-patterns.d.ts +141 -0
- package/dist/src/flow-patterns.js +568 -0
- package/dist/src/gitrepo.d.ts +18 -0
- package/dist/src/gitrepo.js +49 -0
- package/dist/src/host-env.js +5 -0
- package/dist/src/kinds.js +1 -1
- package/dist/src/language.d.ts +17 -0
- package/dist/src/language.js +55 -0
- package/dist/src/layout.d.ts +69 -0
- package/dist/src/layout.js +200 -0
- package/dist/src/library.d.ts +9 -0
- package/dist/src/library.js +30 -3
- package/dist/src/limits.d.ts +84 -0
- package/dist/src/limits.js +287 -0
- package/dist/src/locale.d.ts +47 -0
- package/dist/src/locale.js +177 -0
- package/dist/src/mail.d.ts +88 -0
- package/dist/src/mail.js +247 -0
- package/dist/src/model-credential.d.ts +14 -0
- package/dist/src/model-credential.js +58 -0
- package/dist/src/model-probe.d.ts +3 -0
- package/dist/src/model-probe.js +1 -0
- package/dist/src/notify.d.ts +53 -7
- package/dist/src/notify.js +194 -126
- package/dist/src/openapi.js +5 -3
- package/dist/src/operator.d.ts +201 -0
- package/dist/src/operator.js +251 -0
- package/dist/src/paths.d.ts +28 -0
- package/dist/src/paths.js +55 -0
- package/dist/src/platform.d.ts +43 -3
- package/dist/src/platform.js +9 -1
- package/dist/src/provider-health.js +3 -2
- package/dist/src/providers.d.ts +481 -0
- package/dist/src/providers.js +1105 -6
- package/dist/src/refusal.d.ts +83 -0
- package/dist/src/refusal.js +281 -0
- package/dist/src/run-container.d.ts +149 -13
- package/dist/src/run-container.js +424 -43
- package/dist/src/runner.d.ts +177 -9
- package/dist/src/runner.js +1146 -154
- package/dist/src/runtime.d.ts +52 -0
- package/dist/src/runtime.js +493 -58
- package/dist/src/script-tools.d.ts +17 -2
- package/dist/src/script-tools.js +82 -10
- package/dist/src/secret-files.d.ts +6 -5
- package/dist/src/secret-files.js +24 -11
- package/dist/src/secret-health.d.ts +30 -6
- package/dist/src/secret-health.js +42 -20
- package/dist/src/secrets.d.ts +19 -0
- package/dist/src/secrets.js +155 -86
- package/dist/src/starter.d.ts +9 -0
- package/dist/src/starter.js +92 -11
- package/dist/src/step-exec.d.ts +72 -2
- package/dist/src/step-exec.js +431 -37
- package/dist/src/step-removal.d.ts +15 -0
- package/dist/src/step-removal.js +49 -0
- package/dist/src/storage.d.ts +23 -4
- package/dist/src/storage.js +125 -23
- package/dist/src/store.d.ts +222 -37
- package/dist/src/store.js +349 -62
- package/dist/src/subagents.d.ts +75 -0
- package/dist/src/subagents.js +149 -0
- package/dist/src/test-mode.d.ts +17 -2
- package/dist/src/test-mode.js +53 -13
- package/dist/src/tool-names.d.ts +13 -1
- package/dist/src/tool-names.js +38 -11
- package/dist/src/tool-programs.d.ts +26 -0
- package/dist/src/tool-programs.js +172 -0
- package/dist/src/tool-test.js +104 -12
- package/dist/src/translator.d.ts +8 -2
- package/dist/src/translator.js +69 -23
- package/dist/src/trigger-log.d.ts +1 -1
- package/dist/src/triggers.d.ts +3 -0
- package/dist/src/triggers.js +10 -2
- package/dist/src/untrusted-fetch.d.ts +11 -0
- package/dist/src/untrusted-fetch.js +77 -0
- package/dist/src/web-actions.d.ts +91 -0
- package/dist/src/web-actions.js +199 -0
- package/dist/src/webhook.d.ts +4 -0
- package/dist/src/webhook.js +39 -0
- package/index.ts +4 -0
- package/package.json +3 -3
- package/src/account.ts +149 -0
- package/src/api-tools.ts +32 -4
- package/src/browser-pod.ts +350 -0
- package/src/catalog.ts +2 -1
- package/src/clock.ts +149 -0
- package/src/completions.ts +17 -8
- package/src/confine.ts +218 -3
- package/src/container.ts +112 -5
- package/src/context-tools.ts +1 -1
- package/src/deploy.ts +137 -6
- package/src/egress.ts +13 -1
- package/src/eol.ts +7 -0
- package/src/evals.ts +14 -2
- package/src/flow-lint.ts +253 -1
- package/src/flow-patterns.ts +597 -0
- package/src/gitrepo.ts +48 -0
- package/src/host-env.ts +5 -0
- package/src/kinds.ts +1 -1
- package/src/language.ts +62 -0
- package/src/layout.ts +228 -0
- package/src/library.ts +37 -3
- package/src/limits.ts +290 -0
- package/src/locale.ts +217 -0
- package/src/mail.ts +318 -0
- package/src/model-credential.ts +71 -0
- package/src/model-probe.ts +4 -0
- package/src/notify.ts +189 -103
- package/src/openapi.ts +5 -3
- package/src/operator.ts +300 -0
- package/src/paths.ts +54 -0
- package/src/platform.ts +52 -3
- package/src/provider-health.ts +3 -2
- package/src/providers.ts +1368 -6
- package/src/refusal.ts +320 -0
- package/src/run-container.ts +460 -44
- package/src/runner.ts +1168 -155
- package/src/runtime.ts +487 -55
- package/src/script-tools.ts +81 -10
- package/src/secret-files.ts +22 -10
- package/src/secret-health.ts +64 -19
- package/src/secrets.ts +149 -87
- package/src/starter.ts +93 -11
- package/src/step-exec.ts +447 -38
- package/src/step-removal.ts +59 -0
- package/src/storage.ts +141 -22
- package/src/store.ts +506 -70
- package/src/subagents.ts +189 -0
- package/src/test-mode.ts +51 -11
- package/src/tool-names.ts +40 -11
- package/src/tool-programs.ts +179 -0
- package/src/tool-test.ts +110 -12
- package/src/translator.ts +72 -22
- package/src/trigger-log.ts +1 -0
- package/src/triggers.ts +10 -2
- package/src/untrusted-fetch.ts +88 -0
- package/src/web-actions.ts +237 -0
- package/src/webhook.ts +41 -0
- package/templates/blog-desk/agents/writer/agent.md +1 -1
- package/templates/hello/agents/curator/agent.md +4 -4
- package/templates/hello/agents/notetaker/agent.md +3 -3
package/src/tool-test.ts
CHANGED
|
@@ -19,13 +19,15 @@ import { spawn } from "node:child_process";
|
|
|
19
19
|
import fs from "node:fs";
|
|
20
20
|
import path from "node:path";
|
|
21
21
|
|
|
22
|
-
import { resolveSecrets } from "./secrets.ts";
|
|
22
|
+
import { resolveSecrets, materializeSecrets } from "./secrets.ts";
|
|
23
23
|
import { listAgents, workspaceDir, type ToolDef } from "./store.ts";
|
|
24
24
|
import { libraryDir } from "./library.ts";
|
|
25
25
|
import { secretsUsedByApi } from "./api-tools.ts";
|
|
26
|
+
import { attachOperations, prefetchOpenApi, type OperationSpec } from "./openapi.ts";
|
|
26
27
|
import { commandFor, resolveRunPath } from "./script-tools.ts";
|
|
27
28
|
import { parseRuntime, prepareRuntime } from "./runtime.ts";
|
|
28
29
|
import { hostSafeEnv } from "./host-env.ts";
|
|
30
|
+
import { linkWorkspace, workspaceLinkEnv } from "./confine.ts";
|
|
29
31
|
|
|
30
32
|
export interface ToolTestResult {
|
|
31
33
|
ok: boolean;
|
|
@@ -60,6 +62,32 @@ const clip = (s: string) =>
|
|
|
60
62
|
const substitute = (v: string, env: Record<string, string>) =>
|
|
61
63
|
v.replace(/\$\{([A-Z0-9_]+)\}/g, (_, k) => env[k] ?? "");
|
|
62
64
|
|
|
65
|
+
/**
|
|
66
|
+
* The secrets as a run would hand them over: an oauth2 or service-account
|
|
67
|
+
* secret swapped for a live access token. Without this the tester passed the
|
|
68
|
+
* stored `@oauth2 {…}` recipe itself, so every tool behind Google OAuth failed
|
|
69
|
+
* its Test button while working in every run (gbp-desk review_candidates,
|
|
70
|
+
* 2026-09-28). A refresh that fails is the tester's answer, not a throw.
|
|
71
|
+
*/
|
|
72
|
+
async function liveSecrets(
|
|
73
|
+
env: Record<string, string>,
|
|
74
|
+
ctx: { tenant: string; workspace?: string },
|
|
75
|
+
): Promise<{ env: Record<string, string> } | { error: string }> {
|
|
76
|
+
try {
|
|
77
|
+
return { env: await materializeSecrets(env, ctx) };
|
|
78
|
+
} catch (err) {
|
|
79
|
+
return { error: err instanceof Error ? err.message : String(err) };
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Does a concrete path fit an operation's template (`/contacts/{id}`)? */
|
|
84
|
+
function matchesTemplate(op: OperationSpec, concrete: string): boolean {
|
|
85
|
+
const re = new RegExp(
|
|
86
|
+
"^" + op.path.split(/\{[^/}]+\}/).map((part) => part.replace(/[.*+?^$()|[\]\\]/g, "\\$&")).join("[^/]+") + "$",
|
|
87
|
+
);
|
|
88
|
+
return re.test(concrete);
|
|
89
|
+
}
|
|
90
|
+
|
|
63
91
|
export interface ToolTestInput {
|
|
64
92
|
/** http: the path to try, appended to `base:`. */
|
|
65
93
|
path?: string;
|
|
@@ -80,10 +108,58 @@ export async function testTool(
|
|
|
80
108
|
if (def.kind === "http") {
|
|
81
109
|
const api = def.spec;
|
|
82
110
|
const needed = secretsUsedByApi(api);
|
|
83
|
-
const { env, missing } = resolveSecrets(tenant, needed, workspace);
|
|
111
|
+
const { env: stored, missing } = resolveSecrets(tenant, needed, workspace);
|
|
112
|
+
const live = await liveSecrets(stored, { tenant, workspace });
|
|
113
|
+
if ("error" in live) {
|
|
114
|
+
return done({ ok: false, transport: "http", missingSecrets: missing, summary: "a secret could not be refreshed", detail: live.error });
|
|
115
|
+
}
|
|
116
|
+
const env = live.env;
|
|
84
117
|
|
|
85
118
|
try {
|
|
86
|
-
|
|
119
|
+
let rel = probePath.startsWith("/") || probePath === "" ? probePath : `/${probePath}`;
|
|
120
|
+
let method = api.methods.includes("GET") ? "GET" : api.methods[0];
|
|
121
|
+
|
|
122
|
+
// An `operations:` allowlist binds the tester exactly as it binds a run:
|
|
123
|
+
// the probe may only be a GET to one of the listed operations, and an
|
|
124
|
+
// allowlist that resolves nothing is a failure, as it is in a run. A
|
|
125
|
+
// tester that reached the whole API would be the one door the allowlist
|
|
126
|
+
// left open, and would report "working" for a tool a run cannot use.
|
|
127
|
+
if (api.operations?.length) {
|
|
128
|
+
const fetched = await prefetchOpenApi(tenant, api.openapi ? [api.openapi] : []);
|
|
129
|
+
const { specs, warnings } = attachOperations([api], tenant, workspaceDir(tenant, workspace));
|
|
130
|
+
const ops = specs[0].resolvedOperations ?? [];
|
|
131
|
+
if (!ops.length) {
|
|
132
|
+
return done({
|
|
133
|
+
ok: false, transport: "http", missingSecrets: missing,
|
|
134
|
+
summary: "its operations: allowlist resolved no operations, so a run gets no tools from it",
|
|
135
|
+
detail: [...fetched, ...warnings].join("\n"),
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
const listed = ops.map((o) => `${o.method} ${o.path}`).join("\n");
|
|
139
|
+
const gets = ops.filter((o) => o.method === "GET");
|
|
140
|
+
const pathOnly = rel.split("?")[0];
|
|
141
|
+
if (pathOnly === "") {
|
|
142
|
+
const plain = gets.find((o) => !o.path.includes("{"));
|
|
143
|
+
if (!plain) {
|
|
144
|
+
return done({
|
|
145
|
+
ok: false, transport: "http", missingSecrets: missing,
|
|
146
|
+
summary: gets.length
|
|
147
|
+
? "every GET operation takes a parameter; give a path, like " + gets[0].path
|
|
148
|
+
: "the allowlist has no GET operation, and the tester only sends GET: it will not probe a write",
|
|
149
|
+
detail: `operations:\n${listed}`,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
rel = plain.path;
|
|
153
|
+
} else if (!gets.some((o) => matchesTemplate(o, pathOnly))) {
|
|
154
|
+
return done({
|
|
155
|
+
ok: false, transport: "http", missingSecrets: missing,
|
|
156
|
+
summary: "that path is not one of the tool's GET operations",
|
|
157
|
+
detail: `path: ${pathOnly}\noperations:\n${listed}`,
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
method = "GET";
|
|
161
|
+
}
|
|
162
|
+
|
|
87
163
|
const url = new URL(api.base + rel);
|
|
88
164
|
// Same confinement the agent gets: a probe can't reach another host.
|
|
89
165
|
if (!url.href.startsWith(api.base)) {
|
|
@@ -98,7 +174,6 @@ export async function testTool(
|
|
|
98
174
|
const headers: Record<string, string> = {};
|
|
99
175
|
for (const [k, v] of Object.entries(api.headers)) headers[k] = substitute(v, env);
|
|
100
176
|
|
|
101
|
-
const method = api.methods.includes("GET") ? "GET" : api.methods[0];
|
|
102
177
|
const res = await fetch(url, {
|
|
103
178
|
method,
|
|
104
179
|
headers,
|
|
@@ -203,7 +278,12 @@ export async function testTool(
|
|
|
203
278
|
}
|
|
204
279
|
|
|
205
280
|
const names = Array.isArray(def.spec.secrets) ? def.spec.secrets.map(String) : [];
|
|
206
|
-
const { env, missing } = resolveSecrets(tenant, names, workspace);
|
|
281
|
+
const { env: stored, missing } = resolveSecrets(tenant, names, workspace);
|
|
282
|
+
const live = await liveSecrets(stored, { tenant, workspace });
|
|
283
|
+
if ("error" in live) {
|
|
284
|
+
return done({ ok: false, transport: "script", missingSecrets: missing, summary: "a secret could not be refreshed", detail: live.error });
|
|
285
|
+
}
|
|
286
|
+
const env = live.env;
|
|
207
287
|
// The runtime's own choice of interpreter, not a second table beside it.
|
|
208
288
|
const interpreter =
|
|
209
289
|
typeof def.spec.interpreter === "string" ? def.spec.interpreter : undefined;
|
|
@@ -234,11 +314,24 @@ export async function testTool(
|
|
|
234
314
|
file,
|
|
235
315
|
toolRuntime.interpreters,
|
|
236
316
|
);
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
317
|
+
// The `workspace` link a step holds, held for the test too — a tool
|
|
318
|
+
// whose code says `workspace/state/x` found nothing here while the same
|
|
319
|
+
// call worked in a flow. Only from an agent's folder: at the workspace
|
|
320
|
+
// root there is no depth for it to stand at (the note below says so).
|
|
321
|
+
const link = caller ? linkWorkspace(cwd, dir) : null;
|
|
322
|
+
let ran: Awaited<ReturnType<typeof runOnce>>;
|
|
323
|
+
try {
|
|
324
|
+
ran = await runOnce(cmd, [...args, ...flags], cwd, workspaceLinkEnv({
|
|
325
|
+
...hostSafeBaseEnv(),
|
|
326
|
+
...toolRuntime.env,
|
|
327
|
+
...env,
|
|
328
|
+
}, cwd));
|
|
329
|
+
} finally {
|
|
330
|
+
link?.release();
|
|
331
|
+
// A private build (a concurrent one was wedged) is this test's alone.
|
|
332
|
+
toolRuntime.dispose?.();
|
|
333
|
+
}
|
|
334
|
+
const { code, out } = ran;
|
|
242
335
|
|
|
243
336
|
// A script that needs arguments and got none has not failed — it has not
|
|
244
337
|
// been tested. Saying "exited 2" would send someone looking for a bug.
|
|
@@ -254,7 +347,7 @@ export async function testTool(
|
|
|
254
347
|
detail: clip(
|
|
255
348
|
(caller
|
|
256
349
|
? `ran from agents/${caller.name}/, where a run runs\n\n`
|
|
257
|
-
: `ran from the workspace root — there is no agent to stand in for, so a script that reaches ../../ will
|
|
350
|
+
: `ran from the workspace root — there is no agent to stand in for, so a script that reaches workspace/ or ../../ will not find the workspace there\n\n`) +
|
|
258
351
|
(out || "(no output)"),
|
|
259
352
|
),
|
|
260
353
|
});
|
|
@@ -294,7 +387,12 @@ export async function testTool(
|
|
|
294
387
|
const named = Object.entries(spec.env ?? {})
|
|
295
388
|
.map(([, v]) => String(v).match(/^\$\{([A-Z0-9_]+)\}$/)?.[1])
|
|
296
389
|
.filter((n): n is string => Boolean(n));
|
|
297
|
-
const { env, missing } = resolveSecrets(tenant, named, workspace);
|
|
390
|
+
const { env: stored, missing } = resolveSecrets(tenant, named, workspace);
|
|
391
|
+
const live = await liveSecrets(stored, { tenant, workspace });
|
|
392
|
+
if ("error" in live) {
|
|
393
|
+
return done({ ok: false, transport: "mcp", missingSecrets: missing, summary: "a secret could not be refreshed", detail: live.error });
|
|
394
|
+
}
|
|
395
|
+
const env = live.env;
|
|
298
396
|
const childEnv: NodeJS.ProcessEnv = { ...hostSafeBaseEnv() };
|
|
299
397
|
for (const [k, v] of Object.entries(spec.env ?? {})) childEnv[k] = substitute(String(v), env);
|
|
300
398
|
|
package/src/translator.ts
CHANGED
|
@@ -31,6 +31,7 @@ import crypto from "node:crypto";
|
|
|
31
31
|
import { PROTECTED_PARAMS } from "./providers.ts";
|
|
32
32
|
import { viaEgress } from "./egress.ts";
|
|
33
33
|
import { trimSlashes } from "./paths.ts";
|
|
34
|
+
import { captureRefusalHeaders, isRefusalStatus, refusalLine } from "./refusal.ts";
|
|
34
35
|
|
|
35
36
|
// ------------------------------------------------------------------ shapes
|
|
36
37
|
|
|
@@ -60,6 +61,10 @@ export interface TranslatorSpec {
|
|
|
60
61
|
* upstream request goes through it, and `upstreamKey` is a placeholder
|
|
61
62
|
* the proxy fills. */
|
|
62
63
|
egress?: string;
|
|
64
|
+
/** The step's own zone, from the clock cascade — what a reset time in a
|
|
65
|
+
* refusal is told in. UTC when nothing said otherwise. */
|
|
66
|
+
timezone?: string;
|
|
67
|
+
|
|
63
68
|
}
|
|
64
69
|
|
|
65
70
|
export interface RunningTranslator {
|
|
@@ -419,16 +424,38 @@ export function toResponses(
|
|
|
419
424
|
|
|
420
425
|
// ------------------------------------------------------- response mapping
|
|
421
426
|
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
427
|
+
type Stop = { reason: string; sequence: string | null };
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Why a Chat Completion stopped, in Anthropic's words. `content_filter`
|
|
431
|
+
* (OpenAI, Azure, Gemini's safety block) is a refusal; Mistral's
|
|
432
|
+
* `model_length` is the context window filling, not max_tokens. A stop
|
|
433
|
+
* sequence is named only where the provider says which one matched — vLLM
|
|
434
|
+
* as `stop_reason`, SGLang as `matched_stop` — and only when it is one the
|
|
435
|
+
* request asked for; plain OpenAI says `stop` either way, which reads as
|
|
436
|
+
* end_turn. pause_turn has no counterpart: server-side tools are dropped.
|
|
437
|
+
*/
|
|
438
|
+
function chatStop(choice: Json, sawTool: boolean, stops: readonly string[]): Stop {
|
|
439
|
+
const finish = choice.finish_reason;
|
|
440
|
+
if (finish === "content_filter") return { reason: "refusal", sequence: null };
|
|
441
|
+
if (finish === "length") return { reason: "max_tokens", sequence: null };
|
|
442
|
+
if (finish === "model_length") return { reason: "model_context_window_exceeded", sequence: null };
|
|
443
|
+
if (sawTool || finish === "tool_calls" || finish === "function_call") return { reason: "tool_use", sequence: null };
|
|
444
|
+
const matched = typeof choice.stop_reason === "string" ? choice.stop_reason : choice.matched_stop;
|
|
445
|
+
if (typeof matched === "string" && stops.includes(matched)) return { reason: "stop_sequence", sequence: matched };
|
|
446
|
+
return { reason: "end_turn", sequence: null };
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/** Why a Response stopped: a refusal part or a content filter is a refusal. */
|
|
450
|
+
function responsesStop(incomplete: unknown, sawTool: boolean, sawRefusal: boolean): string {
|
|
451
|
+
if (sawRefusal || incomplete === "content_filter") return "refusal";
|
|
452
|
+
if (incomplete === "max_output_tokens") return "max_tokens";
|
|
453
|
+
if (sawTool) return "tool_use";
|
|
454
|
+
return "end_turn";
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function stopList(stops: unknown): string[] {
|
|
458
|
+
return Array.isArray(stops) ? stops.filter((s): s is string => typeof s === "string") : [];
|
|
432
459
|
}
|
|
433
460
|
|
|
434
461
|
function parseArgs(raw: unknown): unknown {
|
|
@@ -441,7 +468,7 @@ function parseArgs(raw: unknown): unknown {
|
|
|
441
468
|
}
|
|
442
469
|
|
|
443
470
|
/** A finished Chat Completion as an Anthropic message. Pure. */
|
|
444
|
-
export function fromChatCompletion(res: Json, requestedModel: string): Json {
|
|
471
|
+
export function fromChatCompletion(res: Json, requestedModel: string, stopSequences?: unknown): Json {
|
|
445
472
|
const choice = ((res.choices as Json[] | undefined) ?? [])[0] ?? {};
|
|
446
473
|
const msg = (choice.message as Json | undefined) ?? {};
|
|
447
474
|
const content: Json[] = [];
|
|
@@ -457,14 +484,15 @@ export function fromChatCompletion(res: Json, requestedModel: string): Json {
|
|
|
457
484
|
});
|
|
458
485
|
}
|
|
459
486
|
const usage = (res.usage as Json | undefined) ?? {};
|
|
487
|
+
const stop = chatStop(choice, content.some((c) => c.type === "tool_use"), stopList(stopSequences));
|
|
460
488
|
return {
|
|
461
489
|
id: String(res.id ?? `msg_${crypto.randomBytes(8).toString("hex")}`),
|
|
462
490
|
type: "message",
|
|
463
491
|
role: "assistant",
|
|
464
492
|
model: String(res.model ?? requestedModel),
|
|
465
493
|
content,
|
|
466
|
-
stop_reason:
|
|
467
|
-
stop_sequence:
|
|
494
|
+
stop_reason: stop.reason,
|
|
495
|
+
stop_sequence: stop.sequence,
|
|
468
496
|
usage: {
|
|
469
497
|
input_tokens: Number(usage.prompt_tokens ?? 0),
|
|
470
498
|
output_tokens: Number(usage.completion_tokens ?? 0),
|
|
@@ -485,12 +513,15 @@ export class StreamTranslator {
|
|
|
485
513
|
private textIndex: number | null = null;
|
|
486
514
|
/** Chat-Completions tool index → Anthropic block index. */
|
|
487
515
|
private tools = new Map<number, { block: number; argsSeen: boolean }>();
|
|
488
|
-
|
|
516
|
+
/** The last choice's stop fields: finish_reason and any matched stop. */
|
|
517
|
+
private finish: Json = {};
|
|
489
518
|
private usage: { input: number; output: number } | null = null;
|
|
490
519
|
private id = `msg_${crypto.randomBytes(8).toString("hex")}`;
|
|
491
520
|
private readonly model: string;
|
|
492
|
-
|
|
521
|
+
private readonly stops: string[];
|
|
522
|
+
constructor(model: string, stopSequences?: unknown) {
|
|
493
523
|
this.model = model;
|
|
524
|
+
this.stops = stopList(stopSequences);
|
|
494
525
|
}
|
|
495
526
|
|
|
496
527
|
private event(name: string, data: Json): string {
|
|
@@ -534,7 +565,7 @@ export class StreamTranslator {
|
|
|
534
565
|
}
|
|
535
566
|
const choice = ((chunk.choices as Json[] | undefined) ?? [])[0];
|
|
536
567
|
if (!choice) return out;
|
|
537
|
-
if (choice.finish_reason) this.finish = choice.finish_reason;
|
|
568
|
+
if (choice.finish_reason) this.finish = { finish_reason: choice.finish_reason, stop_reason: choice.stop_reason, matched_stop: choice.matched_stop };
|
|
538
569
|
const delta = (choice.delta as Json | undefined) ?? {};
|
|
539
570
|
|
|
540
571
|
const text = typeof delta.content === "string" ? delta.content : "";
|
|
@@ -614,11 +645,11 @@ export class StreamTranslator {
|
|
|
614
645
|
}
|
|
615
646
|
out.push(this.event("content_block_stop", { type: "content_block_stop", index: entry.block }));
|
|
616
647
|
}
|
|
617
|
-
const
|
|
648
|
+
const stop = chatStop(this.finish, this.tools.size > 0, this.stops);
|
|
618
649
|
out.push(
|
|
619
650
|
this.event("message_delta", {
|
|
620
651
|
type: "message_delta",
|
|
621
|
-
delta: { stop_reason: reason, stop_sequence:
|
|
652
|
+
delta: { stop_reason: stop.reason, stop_sequence: stop.sequence },
|
|
622
653
|
usage: { input_tokens: this.usage?.input ?? 0, output_tokens: this.usage?.output ?? 0 },
|
|
623
654
|
}),
|
|
624
655
|
);
|
|
@@ -648,7 +679,10 @@ export function fromResponses(res: Json, requestedModel: string): Json {
|
|
|
648
679
|
}
|
|
649
680
|
const usage = (res.usage as Json | undefined) ?? {};
|
|
650
681
|
const incomplete = (res.incomplete_details as Json | undefined)?.reason;
|
|
651
|
-
const
|
|
682
|
+
const refused = (Array.isArray(res.output) ? (res.output as Json[]) : []).some(
|
|
683
|
+
(item) => item.type === "message" && Array.isArray(item.content) && (item.content as Json[]).some((c) => c.type === "refusal"),
|
|
684
|
+
);
|
|
685
|
+
const stop = responsesStop(incomplete, content.some((c) => c.type === "tool_use"), refused);
|
|
652
686
|
return {
|
|
653
687
|
id: String(res.id ?? `msg_${crypto.randomBytes(8).toString("hex")}`),
|
|
654
688
|
type: "message",
|
|
@@ -679,6 +713,7 @@ export class ResponsesStreamTranslator {
|
|
|
679
713
|
private usage: { input: number; output: number } | null = null;
|
|
680
714
|
private incomplete: string | null = null;
|
|
681
715
|
private sawTool = false;
|
|
716
|
+
private sawRefusal = false;
|
|
682
717
|
private id = `msg_${crypto.randomBytes(8).toString("hex")}`;
|
|
683
718
|
private readonly model: string;
|
|
684
719
|
constructor(model: string) {
|
|
@@ -743,6 +778,7 @@ export class ResponsesStreamTranslator {
|
|
|
743
778
|
if (type === "response.output_text.delta" || type === "response.refusal.delta") {
|
|
744
779
|
const entry = this.blocks.get(Number(ev.output_index));
|
|
745
780
|
const text = String(ev.delta ?? "");
|
|
781
|
+
if (type === "response.refusal.delta") this.sawRefusal = true;
|
|
746
782
|
if (entry && entry.kind === "text" && text) {
|
|
747
783
|
out.push(this.event("content_block_delta", { type: "content_block_delta", index: entry.block, delta: { type: "text_delta", text } }));
|
|
748
784
|
}
|
|
@@ -793,7 +829,7 @@ export class ResponsesStreamTranslator {
|
|
|
793
829
|
}
|
|
794
830
|
out.push(this.event("content_block_stop", { type: "content_block_stop", index: entry.block }));
|
|
795
831
|
}
|
|
796
|
-
const reason = this.
|
|
832
|
+
const reason = responsesStop(this.incomplete, this.sawTool, this.sawRefusal);
|
|
797
833
|
out.push(
|
|
798
834
|
this.event("message_delta", {
|
|
799
835
|
type: "message_delta",
|
|
@@ -913,6 +949,20 @@ export async function startTranslator(spec: TranslatorSpec): Promise<RunningTran
|
|
|
913
949
|
const text = await upstream.text();
|
|
914
950
|
const message = upstreamMessage(text, upstream.status);
|
|
915
951
|
log.push(`POST ${label} → ${upstream.status} (${Date.now() - started}ms): ${message.slice(0, 160)}`);
|
|
952
|
+
// The refusal in words, from the headers the response already
|
|
953
|
+
// carried. Only on a refusal, and only the allow-listed headers:
|
|
954
|
+
// a 400 about a malformed body is the work's problem, not the
|
|
955
|
+
// supply's, and explaining it as one would mislead.
|
|
956
|
+
if (isRefusalStatus(upstream.status)) {
|
|
957
|
+
log.push(
|
|
958
|
+
refusalLine({
|
|
959
|
+
status: upstream.status,
|
|
960
|
+
headers: captureRefusalHeaders(upstream.headers),
|
|
961
|
+
timezone: spec.timezone,
|
|
962
|
+
provider: label,
|
|
963
|
+
}),
|
|
964
|
+
);
|
|
965
|
+
}
|
|
916
966
|
// The status crosses unchanged, so a 401 or 429 from the provider
|
|
917
967
|
// reads as one to the SDK — and to the fallback decision upstream.
|
|
918
968
|
return send(upstream.status, errorBody(upstream.status, message));
|
|
@@ -928,11 +978,11 @@ export async function startTranslator(spec: TranslatorSpec): Promise<RunningTran
|
|
|
928
978
|
return send(502, errorBody(502, `translator: ${label} answered with something that is not JSON`));
|
|
929
979
|
}
|
|
930
980
|
log.push(`POST ${label} → 200 (${Date.now() - started}ms, ${model})`);
|
|
931
|
-
return send(200, JSON.stringify(responses ? fromResponses(parsed, model) : fromChatCompletion(parsed, model)));
|
|
981
|
+
return send(200, JSON.stringify(responses ? fromResponses(parsed, model) : fromChatCompletion(parsed, model, body.stop_sequences)));
|
|
932
982
|
}
|
|
933
983
|
|
|
934
984
|
res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache", connection: "keep-alive" });
|
|
935
|
-
const machine = responses ? new ResponsesStreamTranslator(model) : new StreamTranslator(model);
|
|
985
|
+
const machine = responses ? new ResponsesStreamTranslator(model) : new StreamTranslator(model, body.stop_sequences);
|
|
936
986
|
const reader = upstream.body?.getReader();
|
|
937
987
|
if (!reader) {
|
|
938
988
|
for (const e of machine.finishStream()) res.write(e);
|
package/src/trigger-log.ts
CHANGED
package/src/triggers.ts
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
|
|
15
15
|
import crypto from "node:crypto";
|
|
16
16
|
import { platform } from "./platform.ts";
|
|
17
|
-
import { listFlows, readRun, type FlowInfo, type FlowStep, type RunRecord } from "./store.ts";
|
|
17
|
+
import { listFlows, readRun, runVerdict, type FlowInfo, type FlowStep, type RunRecord } from "./store.ts";
|
|
18
18
|
import { getSecret } from "./secrets.ts";
|
|
19
19
|
|
|
20
20
|
/** The last step's result — the same reading runner.ts makes, kept local so
|
|
@@ -36,6 +36,13 @@ export function withTask(steps: FlowStep[], tag: string, body: string): FlowStep
|
|
|
36
36
|
|
|
37
37
|
// ------------------------------------------------------------ trigger: flow
|
|
38
38
|
|
|
39
|
+
/** Which `on:` a settled run answers to: failed, blocked (completed, but
|
|
40
|
+
* its work refused itself), or completed. */
|
|
41
|
+
export function endingOf(run: RunRecord): "completed" | "failed" | "blocked" {
|
|
42
|
+
if (run.status === "failed") return "failed";
|
|
43
|
+
return runVerdict(run) === "BLOCKED" ? "blocked" : "completed";
|
|
44
|
+
}
|
|
45
|
+
|
|
39
46
|
/** The flows a finished run should start, by this workspace's own files. */
|
|
40
47
|
export function chainedFlows(tenant: string, workspace: string, finished: RunRecord): FlowInfo[] {
|
|
41
48
|
if (finished.status !== "completed" && finished.status !== "failed") return [];
|
|
@@ -48,7 +55,7 @@ export function chainedFlows(tenant: string, workspace: string, finished: RunRec
|
|
|
48
55
|
// A flow chained on itself is a loop with no bound — refused here and
|
|
49
56
|
// by foldrun check, never started.
|
|
50
57
|
f.name !== finished.flow &&
|
|
51
|
-
(f.on === "any" || f.on === finished
|
|
58
|
+
(f.on === "any" || f.on === endingOf(finished)),
|
|
52
59
|
);
|
|
53
60
|
}
|
|
54
61
|
|
|
@@ -66,6 +73,7 @@ export async function fireChainedFlows(tenant: string, workspace: string, finish
|
|
|
66
73
|
const result = (runResult(finished) ?? "").slice(0, 20_000);
|
|
67
74
|
const body =
|
|
68
75
|
`run: ${finished.id}\nflow: ${finished.flow}\nstatus: ${finished.status}\n` +
|
|
76
|
+
(runVerdict(finished) ? `verdict: ${runVerdict(finished)}\n` : "") +
|
|
69
77
|
(finished.summary ? `summary: ${finished.summary}\n` : "") +
|
|
70
78
|
(result ? `\n${result}` : "");
|
|
71
79
|
for (const flow of flows) {
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// fetch() for a URL a tenant chose, redirects included.
|
|
2
|
+
//
|
|
3
|
+
// platform.fetchUntrusted is the seam: on a platform it refuses private and
|
|
4
|
+
// cluster addresses (by name, by IP literal, and at connect time through a
|
|
5
|
+
// guarded DNS lookup). But it was handed fetch's default `redirect:
|
|
6
|
+
// "follow"`, and a redirect is followed inside fetch, past the seam: a
|
|
7
|
+
// public host answering `302 Location: http://169.254.169.254/…` sent the
|
|
8
|
+
// platform to the metadata endpoint, because an IP literal never reaches the
|
|
9
|
+
// DNS lookup the connect-time guard lives in. And the OAuth token POSTs
|
|
10
|
+
// carried a client secret to wherever a 307 pointed.
|
|
11
|
+
//
|
|
12
|
+
// So redirects are followed here, one hop at a time, each hop through the
|
|
13
|
+
// seam again — every address a request ends up at is checked the same way
|
|
14
|
+
// the first one was. A hop to another origin keeps only the headers that
|
|
15
|
+
// cannot carry a credential, and a caller whose request IS a credential
|
|
16
|
+
// (a token exchange, a provider key in a header) follows none at all.
|
|
17
|
+
|
|
18
|
+
import { platform } from "./platform.ts";
|
|
19
|
+
|
|
20
|
+
/** How many redirects one request may follow. fetch's own limit is 20;
|
|
21
|
+
* nothing a tenant points the platform at needs more than a couple. */
|
|
22
|
+
export const MAX_REDIRECTS = 5;
|
|
23
|
+
|
|
24
|
+
const REDIRECT = new Set([301, 302, 303, 307, 308]);
|
|
25
|
+
|
|
26
|
+
/** Headers that may cross to another origin: none of them can carry a
|
|
27
|
+
* credential. Everything else — Authorization, a cookie, an x-api-key, a
|
|
28
|
+
* webhook's signature — stays with the origin it was meant for. */
|
|
29
|
+
const CROSS_ORIGIN_SAFE = new Set(["accept", "accept-language", "content-type", "user-agent"]);
|
|
30
|
+
|
|
31
|
+
export class RedirectRefused extends Error {
|
|
32
|
+
constructor(message: string) {
|
|
33
|
+
super(message);
|
|
34
|
+
this.name = "RedirectRefused";
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function fetchUntrusted(
|
|
39
|
+
url: string,
|
|
40
|
+
init: RequestInit = {},
|
|
41
|
+
opts: {
|
|
42
|
+
/** The request carries a credential (in its body or its headers):
|
|
43
|
+
* follow a redirect only within the origin it was sent to. */
|
|
44
|
+
sameOrigin?: boolean;
|
|
45
|
+
} = {},
|
|
46
|
+
): Promise<Response> {
|
|
47
|
+
let current = url;
|
|
48
|
+
let req: RequestInit = { ...init };
|
|
49
|
+
for (let hop = 0; ; hop++) {
|
|
50
|
+
const res = await platform.fetchUntrusted(current, { ...req, redirect: "manual" });
|
|
51
|
+
const location = REDIRECT.has(res.status) ? res.headers.get("location") : null;
|
|
52
|
+
if (!location) return res;
|
|
53
|
+
// The body of a redirect is never read; free the socket.
|
|
54
|
+
await res.body?.cancel().catch(() => {});
|
|
55
|
+
if (hop >= MAX_REDIRECTS) throw new RedirectRefused(`${url}: more than ${MAX_REDIRECTS} redirects — not followed`);
|
|
56
|
+
let next: URL;
|
|
57
|
+
try {
|
|
58
|
+
next = new URL(location, current);
|
|
59
|
+
} catch {
|
|
60
|
+
throw new RedirectRefused(`${current} redirected to "${location}", which is not a URL`);
|
|
61
|
+
}
|
|
62
|
+
if (next.protocol !== "https:" && next.protocol !== "http:") {
|
|
63
|
+
throw new RedirectRefused(`${current} redirected to a ${next.protocol} URL — not followed`);
|
|
64
|
+
}
|
|
65
|
+
const from = new URL(current);
|
|
66
|
+
const crossOrigin = next.origin !== from.origin;
|
|
67
|
+
if (crossOrigin && opts.sameOrigin) {
|
|
68
|
+
throw new RedirectRefused(
|
|
69
|
+
`${from.origin} redirected to ${next.origin} — not followed: this request carries a credential, and it goes only where it was sent`,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
// fetch's own rewrite: a 303, and a 301/302 answering a POST, is
|
|
73
|
+
// followed as a GET with no body; a 307/308 repeats the request as it was.
|
|
74
|
+
const method = (req.method ?? "GET").toUpperCase();
|
|
75
|
+
const headers = new Headers(req.headers);
|
|
76
|
+
if ((res.status === 303 && method !== "GET" && method !== "HEAD") || ((res.status === 301 || res.status === 302) && method === "POST")) {
|
|
77
|
+
req = { ...req, method: "GET", body: undefined };
|
|
78
|
+
headers.delete("content-type");
|
|
79
|
+
}
|
|
80
|
+
if (crossOrigin) {
|
|
81
|
+
const names: string[] = [];
|
|
82
|
+
headers.forEach((_, name) => names.push(name));
|
|
83
|
+
for (const name of names) if (!CROSS_ORIGIN_SAFE.has(name)) headers.delete(name);
|
|
84
|
+
}
|
|
85
|
+
req = { ...req, headers };
|
|
86
|
+
current = next.toString();
|
|
87
|
+
}
|
|
88
|
+
}
|