@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/dist/src/tool-test.js
CHANGED
|
@@ -17,13 +17,15 @@
|
|
|
17
17
|
import { spawn } from "node:child_process";
|
|
18
18
|
import fs from "node:fs";
|
|
19
19
|
import path from "node:path";
|
|
20
|
-
import { resolveSecrets } from "./secrets.js";
|
|
20
|
+
import { resolveSecrets, materializeSecrets } from "./secrets.js";
|
|
21
21
|
import { listAgents, workspaceDir } from "./store.js";
|
|
22
22
|
import { libraryDir } from "./library.js";
|
|
23
23
|
import { secretsUsedByApi } from "./api-tools.js";
|
|
24
|
+
import { attachOperations, prefetchOpenApi } from "./openapi.js";
|
|
24
25
|
import { commandFor, resolveRunPath } from "./script-tools.js";
|
|
25
26
|
import { parseRuntime, prepareRuntime } from "./runtime.js";
|
|
26
27
|
import { hostSafeEnv } from "./host-env.js";
|
|
28
|
+
import { linkWorkspace, workspaceLinkEnv } from "./confine.js";
|
|
27
29
|
const MAX_DETAIL = 4000;
|
|
28
30
|
const TIMEOUT_MS = 15_000;
|
|
29
31
|
// A tool test runs tenant-authored code directly on the host — real runs are
|
|
@@ -39,6 +41,26 @@ const hostSafeBaseEnv = () => hostSafeEnv();
|
|
|
39
41
|
const clip = (s) => s.length > MAX_DETAIL ? `${s.slice(0, MAX_DETAIL)}\n… truncated` : s;
|
|
40
42
|
/** `${NAME}` → value, for headers and query. Same rule the runtime uses. */
|
|
41
43
|
const substitute = (v, env) => v.replace(/\$\{([A-Z0-9_]+)\}/g, (_, k) => env[k] ?? "");
|
|
44
|
+
/**
|
|
45
|
+
* The secrets as a run would hand them over: an oauth2 or service-account
|
|
46
|
+
* secret swapped for a live access token. Without this the tester passed the
|
|
47
|
+
* stored `@oauth2 {…}` recipe itself, so every tool behind Google OAuth failed
|
|
48
|
+
* its Test button while working in every run (gbp-desk review_candidates,
|
|
49
|
+
* 2026-09-28). A refresh that fails is the tester's answer, not a throw.
|
|
50
|
+
*/
|
|
51
|
+
async function liveSecrets(env, ctx) {
|
|
52
|
+
try {
|
|
53
|
+
return { env: await materializeSecrets(env, ctx) };
|
|
54
|
+
}
|
|
55
|
+
catch (err) {
|
|
56
|
+
return { error: err instanceof Error ? err.message : String(err) };
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/** Does a concrete path fit an operation's template (`/contacts/{id}`)? */
|
|
60
|
+
function matchesTemplate(op, concrete) {
|
|
61
|
+
const re = new RegExp("^" + op.path.split(/\{[^/}]+\}/).map((part) => part.replace(/[.*+?^$()|[\]\\]/g, "\\$&")).join("[^/]+") + "$");
|
|
62
|
+
return re.test(concrete);
|
|
63
|
+
}
|
|
42
64
|
export async function testTool(tenant, workspace, def, input = {}) {
|
|
43
65
|
const probePath = input.path ?? "";
|
|
44
66
|
const started = Date.now();
|
|
@@ -46,9 +68,56 @@ export async function testTool(tenant, workspace, def, input = {}) {
|
|
|
46
68
|
if (def.kind === "http") {
|
|
47
69
|
const api = def.spec;
|
|
48
70
|
const needed = secretsUsedByApi(api);
|
|
49
|
-
const { env, missing } = resolveSecrets(tenant, needed, workspace);
|
|
71
|
+
const { env: stored, missing } = resolveSecrets(tenant, needed, workspace);
|
|
72
|
+
const live = await liveSecrets(stored, { tenant, workspace });
|
|
73
|
+
if ("error" in live) {
|
|
74
|
+
return done({ ok: false, transport: "http", missingSecrets: missing, summary: "a secret could not be refreshed", detail: live.error });
|
|
75
|
+
}
|
|
76
|
+
const env = live.env;
|
|
50
77
|
try {
|
|
51
|
-
|
|
78
|
+
let rel = probePath.startsWith("/") || probePath === "" ? probePath : `/${probePath}`;
|
|
79
|
+
let method = api.methods.includes("GET") ? "GET" : api.methods[0];
|
|
80
|
+
// An `operations:` allowlist binds the tester exactly as it binds a run:
|
|
81
|
+
// the probe may only be a GET to one of the listed operations, and an
|
|
82
|
+
// allowlist that resolves nothing is a failure, as it is in a run. A
|
|
83
|
+
// tester that reached the whole API would be the one door the allowlist
|
|
84
|
+
// left open, and would report "working" for a tool a run cannot use.
|
|
85
|
+
if (api.operations?.length) {
|
|
86
|
+
const fetched = await prefetchOpenApi(tenant, api.openapi ? [api.openapi] : []);
|
|
87
|
+
const { specs, warnings } = attachOperations([api], tenant, workspaceDir(tenant, workspace));
|
|
88
|
+
const ops = specs[0].resolvedOperations ?? [];
|
|
89
|
+
if (!ops.length) {
|
|
90
|
+
return done({
|
|
91
|
+
ok: false, transport: "http", missingSecrets: missing,
|
|
92
|
+
summary: "its operations: allowlist resolved no operations, so a run gets no tools from it",
|
|
93
|
+
detail: [...fetched, ...warnings].join("\n"),
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
const listed = ops.map((o) => `${o.method} ${o.path}`).join("\n");
|
|
97
|
+
const gets = ops.filter((o) => o.method === "GET");
|
|
98
|
+
const pathOnly = rel.split("?")[0];
|
|
99
|
+
if (pathOnly === "") {
|
|
100
|
+
const plain = gets.find((o) => !o.path.includes("{"));
|
|
101
|
+
if (!plain) {
|
|
102
|
+
return done({
|
|
103
|
+
ok: false, transport: "http", missingSecrets: missing,
|
|
104
|
+
summary: gets.length
|
|
105
|
+
? "every GET operation takes a parameter; give a path, like " + gets[0].path
|
|
106
|
+
: "the allowlist has no GET operation, and the tester only sends GET: it will not probe a write",
|
|
107
|
+
detail: `operations:\n${listed}`,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
rel = plain.path;
|
|
111
|
+
}
|
|
112
|
+
else if (!gets.some((o) => matchesTemplate(o, pathOnly))) {
|
|
113
|
+
return done({
|
|
114
|
+
ok: false, transport: "http", missingSecrets: missing,
|
|
115
|
+
summary: "that path is not one of the tool's GET operations",
|
|
116
|
+
detail: `path: ${pathOnly}\noperations:\n${listed}`,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
method = "GET";
|
|
120
|
+
}
|
|
52
121
|
const url = new URL(api.base + rel);
|
|
53
122
|
// Same confinement the agent gets: a probe can't reach another host.
|
|
54
123
|
if (!url.href.startsWith(api.base)) {
|
|
@@ -63,7 +132,6 @@ export async function testTool(tenant, workspace, def, input = {}) {
|
|
|
63
132
|
const headers = {};
|
|
64
133
|
for (const [k, v] of Object.entries(api.headers))
|
|
65
134
|
headers[k] = substitute(v, env);
|
|
66
|
-
const method = api.methods.includes("GET") ? "GET" : api.methods[0];
|
|
67
135
|
const res = await fetch(url, {
|
|
68
136
|
method,
|
|
69
137
|
headers,
|
|
@@ -162,7 +230,12 @@ export async function testTool(tenant, workspace, def, input = {}) {
|
|
|
162
230
|
});
|
|
163
231
|
}
|
|
164
232
|
const names = Array.isArray(def.spec.secrets) ? def.spec.secrets.map(String) : [];
|
|
165
|
-
const { env, missing } = resolveSecrets(tenant, names, workspace);
|
|
233
|
+
const { env: stored, missing } = resolveSecrets(tenant, names, workspace);
|
|
234
|
+
const live = await liveSecrets(stored, { tenant, workspace });
|
|
235
|
+
if ("error" in live) {
|
|
236
|
+
return done({ ok: false, transport: "script", missingSecrets: missing, summary: "a secret could not be refreshed", detail: live.error });
|
|
237
|
+
}
|
|
238
|
+
const env = live.env;
|
|
166
239
|
// The runtime's own choice of interpreter, not a second table beside it.
|
|
167
240
|
const interpreter = typeof def.spec.interpreter === "string" ? def.spec.interpreter : undefined;
|
|
168
241
|
// Declared args become long flags, exactly as the runtime passes them.
|
|
@@ -187,11 +260,25 @@ export async function testTool(tenant, workspace, def, input = {}) {
|
|
|
187
260
|
});
|
|
188
261
|
}
|
|
189
262
|
const { cmd, args } = commandFor({ interpreter, run: run, name: "", description: "", args: {} }, file, toolRuntime.interpreters);
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
263
|
+
// The `workspace` link a step holds, held for the test too — a tool
|
|
264
|
+
// whose code says `workspace/state/x` found nothing here while the same
|
|
265
|
+
// call worked in a flow. Only from an agent's folder: at the workspace
|
|
266
|
+
// root there is no depth for it to stand at (the note below says so).
|
|
267
|
+
const link = caller ? linkWorkspace(cwd, dir) : null;
|
|
268
|
+
let ran;
|
|
269
|
+
try {
|
|
270
|
+
ran = await runOnce(cmd, [...args, ...flags], cwd, workspaceLinkEnv({
|
|
271
|
+
...hostSafeBaseEnv(),
|
|
272
|
+
...toolRuntime.env,
|
|
273
|
+
...env,
|
|
274
|
+
}, cwd));
|
|
275
|
+
}
|
|
276
|
+
finally {
|
|
277
|
+
link?.release();
|
|
278
|
+
// A private build (a concurrent one was wedged) is this test's alone.
|
|
279
|
+
toolRuntime.dispose?.();
|
|
280
|
+
}
|
|
281
|
+
const { code, out } = ran;
|
|
195
282
|
// A script that needs arguments and got none has not failed — it has not
|
|
196
283
|
// been tested. Saying "exited 2" would send someone looking for a bug.
|
|
197
284
|
const untested = code !== 0 && declared.length > 0 && flags.length === 0;
|
|
@@ -204,7 +291,7 @@ export async function testTool(tenant, workspace, def, input = {}) {
|
|
|
204
291
|
: code === 0 ? "exited 0" : `exited ${code ?? "with an error"}`,
|
|
205
292
|
detail: clip((caller
|
|
206
293
|
? `ran from agents/${caller.name}/, where a run runs\n\n`
|
|
207
|
-
: `ran from the workspace root — there is no agent to stand in for, so a script that reaches ../../ will
|
|
294
|
+
: `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`) +
|
|
208
295
|
(out || "(no output)")),
|
|
209
296
|
});
|
|
210
297
|
}
|
|
@@ -241,7 +328,12 @@ export async function testTool(tenant, workspace, def, input = {}) {
|
|
|
241
328
|
const named = Object.entries(spec.env ?? {})
|
|
242
329
|
.map(([, v]) => String(v).match(/^\$\{([A-Z0-9_]+)\}$/)?.[1])
|
|
243
330
|
.filter((n) => Boolean(n));
|
|
244
|
-
const { env, missing } = resolveSecrets(tenant, named, workspace);
|
|
331
|
+
const { env: stored, missing } = resolveSecrets(tenant, named, workspace);
|
|
332
|
+
const live = await liveSecrets(stored, { tenant, workspace });
|
|
333
|
+
if ("error" in live) {
|
|
334
|
+
return done({ ok: false, transport: "mcp", missingSecrets: missing, summary: "a secret could not be refreshed", detail: live.error });
|
|
335
|
+
}
|
|
336
|
+
const env = live.env;
|
|
245
337
|
const childEnv = { ...hostSafeBaseEnv() };
|
|
246
338
|
for (const [k, v] of Object.entries(spec.env ?? {}))
|
|
247
339
|
childEnv[k] = substitute(String(v), env);
|
package/dist/src/translator.d.ts
CHANGED
|
@@ -22,6 +22,9 @@ export interface TranslatorSpec {
|
|
|
22
22
|
* upstream request goes through it, and `upstreamKey` is a placeholder
|
|
23
23
|
* the proxy fills. */
|
|
24
24
|
egress?: string;
|
|
25
|
+
/** The step's own zone, from the clock cascade — what a reset time in a
|
|
26
|
+
* refusal is told in. UTC when nothing said otherwise. */
|
|
27
|
+
timezone?: string;
|
|
25
28
|
}
|
|
26
29
|
export interface RunningTranslator {
|
|
27
30
|
port: number;
|
|
@@ -81,7 +84,7 @@ export declare function toResponses(req: Json, opts: {
|
|
|
81
84
|
params?: Record<string, unknown>;
|
|
82
85
|
}, drop?: Dropped): Json;
|
|
83
86
|
/** A finished Chat Completion as an Anthropic message. Pure. */
|
|
84
|
-
export declare function fromChatCompletion(res: Json, requestedModel: string): Json;
|
|
87
|
+
export declare function fromChatCompletion(res: Json, requestedModel: string, stopSequences?: unknown): Json;
|
|
85
88
|
/**
|
|
86
89
|
* The streaming half: Chat-Completions chunks in, Anthropic SSE events out.
|
|
87
90
|
* A state machine because the two streams disagree about structure — one
|
|
@@ -95,11 +98,13 @@ export declare class StreamTranslator {
|
|
|
95
98
|
private textIndex;
|
|
96
99
|
/** Chat-Completions tool index → Anthropic block index. */
|
|
97
100
|
private tools;
|
|
101
|
+
/** The last choice's stop fields: finish_reason and any matched stop. */
|
|
98
102
|
private finish;
|
|
99
103
|
private usage;
|
|
100
104
|
private id;
|
|
101
105
|
private readonly model;
|
|
102
|
-
|
|
106
|
+
private readonly stops;
|
|
107
|
+
constructor(model: string, stopSequences?: unknown);
|
|
103
108
|
private event;
|
|
104
109
|
private start;
|
|
105
110
|
private closeText;
|
|
@@ -125,6 +130,7 @@ export declare class ResponsesStreamTranslator {
|
|
|
125
130
|
private usage;
|
|
126
131
|
private incomplete;
|
|
127
132
|
private sawTool;
|
|
133
|
+
private sawRefusal;
|
|
128
134
|
private id;
|
|
129
135
|
private readonly model;
|
|
130
136
|
constructor(model: string);
|
package/dist/src/translator.js
CHANGED
|
@@ -30,6 +30,7 @@ import crypto from "node:crypto";
|
|
|
30
30
|
import { PROTECTED_PARAMS } from "./providers.js";
|
|
31
31
|
import { viaEgress } from "./egress.js";
|
|
32
32
|
import { trimSlashes } from "./paths.js";
|
|
33
|
+
import { captureRefusalHeaders, isRefusalStatus, refusalLine } from "./refusal.js";
|
|
33
34
|
function dropped() {
|
|
34
35
|
const seen = new Set();
|
|
35
36
|
return {
|
|
@@ -391,17 +392,42 @@ export function toResponses(req, opts, drop = dropped()) {
|
|
|
391
392
|
}
|
|
392
393
|
return out;
|
|
393
394
|
}
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
395
|
+
/**
|
|
396
|
+
* Why a Chat Completion stopped, in Anthropic's words. `content_filter`
|
|
397
|
+
* (OpenAI, Azure, Gemini's safety block) is a refusal; Mistral's
|
|
398
|
+
* `model_length` is the context window filling, not max_tokens. A stop
|
|
399
|
+
* sequence is named only where the provider says which one matched — vLLM
|
|
400
|
+
* as `stop_reason`, SGLang as `matched_stop` — and only when it is one the
|
|
401
|
+
* request asked for; plain OpenAI says `stop` either way, which reads as
|
|
402
|
+
* end_turn. pause_turn has no counterpart: server-side tools are dropped.
|
|
403
|
+
*/
|
|
404
|
+
function chatStop(choice, sawTool, stops) {
|
|
405
|
+
const finish = choice.finish_reason;
|
|
406
|
+
if (finish === "content_filter")
|
|
407
|
+
return { reason: "refusal", sequence: null };
|
|
408
|
+
if (finish === "length")
|
|
409
|
+
return { reason: "max_tokens", sequence: null };
|
|
410
|
+
if (finish === "model_length")
|
|
411
|
+
return { reason: "model_context_window_exceeded", sequence: null };
|
|
412
|
+
if (sawTool || finish === "tool_calls" || finish === "function_call")
|
|
413
|
+
return { reason: "tool_use", sequence: null };
|
|
414
|
+
const matched = typeof choice.stop_reason === "string" ? choice.stop_reason : choice.matched_stop;
|
|
415
|
+
if (typeof matched === "string" && stops.includes(matched))
|
|
416
|
+
return { reason: "stop_sequence", sequence: matched };
|
|
417
|
+
return { reason: "end_turn", sequence: null };
|
|
418
|
+
}
|
|
419
|
+
/** Why a Response stopped: a refusal part or a content filter is a refusal. */
|
|
420
|
+
function responsesStop(incomplete, sawTool, sawRefusal) {
|
|
421
|
+
if (sawRefusal || incomplete === "content_filter")
|
|
422
|
+
return "refusal";
|
|
423
|
+
if (incomplete === "max_output_tokens")
|
|
424
|
+
return "max_tokens";
|
|
425
|
+
if (sawTool)
|
|
426
|
+
return "tool_use";
|
|
427
|
+
return "end_turn";
|
|
428
|
+
}
|
|
429
|
+
function stopList(stops) {
|
|
430
|
+
return Array.isArray(stops) ? stops.filter((s) => typeof s === "string") : [];
|
|
405
431
|
}
|
|
406
432
|
function parseArgs(raw) {
|
|
407
433
|
if (typeof raw !== "string" || !raw.trim())
|
|
@@ -414,7 +440,7 @@ function parseArgs(raw) {
|
|
|
414
440
|
}
|
|
415
441
|
}
|
|
416
442
|
/** A finished Chat Completion as an Anthropic message. Pure. */
|
|
417
|
-
export function fromChatCompletion(res, requestedModel) {
|
|
443
|
+
export function fromChatCompletion(res, requestedModel, stopSequences) {
|
|
418
444
|
const choice = (res.choices ?? [])[0] ?? {};
|
|
419
445
|
const msg = choice.message ?? {};
|
|
420
446
|
const content = [];
|
|
@@ -431,14 +457,15 @@ export function fromChatCompletion(res, requestedModel) {
|
|
|
431
457
|
});
|
|
432
458
|
}
|
|
433
459
|
const usage = res.usage ?? {};
|
|
460
|
+
const stop = chatStop(choice, content.some((c) => c.type === "tool_use"), stopList(stopSequences));
|
|
434
461
|
return {
|
|
435
462
|
id: String(res.id ?? `msg_${crypto.randomBytes(8).toString("hex")}`),
|
|
436
463
|
type: "message",
|
|
437
464
|
role: "assistant",
|
|
438
465
|
model: String(res.model ?? requestedModel),
|
|
439
466
|
content,
|
|
440
|
-
stop_reason:
|
|
441
|
-
stop_sequence:
|
|
467
|
+
stop_reason: stop.reason,
|
|
468
|
+
stop_sequence: stop.sequence,
|
|
442
469
|
usage: {
|
|
443
470
|
input_tokens: Number(usage.prompt_tokens ?? 0),
|
|
444
471
|
output_tokens: Number(usage.completion_tokens ?? 0),
|
|
@@ -458,12 +485,15 @@ export class StreamTranslator {
|
|
|
458
485
|
textIndex = null;
|
|
459
486
|
/** Chat-Completions tool index → Anthropic block index. */
|
|
460
487
|
tools = new Map();
|
|
461
|
-
|
|
488
|
+
/** The last choice's stop fields: finish_reason and any matched stop. */
|
|
489
|
+
finish = {};
|
|
462
490
|
usage = null;
|
|
463
491
|
id = `msg_${crypto.randomBytes(8).toString("hex")}`;
|
|
464
492
|
model;
|
|
465
|
-
|
|
493
|
+
stops;
|
|
494
|
+
constructor(model, stopSequences) {
|
|
466
495
|
this.model = model;
|
|
496
|
+
this.stops = stopList(stopSequences);
|
|
467
497
|
}
|
|
468
498
|
event(name, data) {
|
|
469
499
|
return `event: ${name}\ndata: ${JSON.stringify(data)}\n\n`;
|
|
@@ -508,7 +538,7 @@ export class StreamTranslator {
|
|
|
508
538
|
if (!choice)
|
|
509
539
|
return out;
|
|
510
540
|
if (choice.finish_reason)
|
|
511
|
-
this.finish = choice.finish_reason;
|
|
541
|
+
this.finish = { finish_reason: choice.finish_reason, stop_reason: choice.stop_reason, matched_stop: choice.matched_stop };
|
|
512
542
|
const delta = choice.delta ?? {};
|
|
513
543
|
const text = typeof delta.content === "string" ? delta.content : "";
|
|
514
544
|
if (text) {
|
|
@@ -575,10 +605,10 @@ export class StreamTranslator {
|
|
|
575
605
|
}
|
|
576
606
|
out.push(this.event("content_block_stop", { type: "content_block_stop", index: entry.block }));
|
|
577
607
|
}
|
|
578
|
-
const
|
|
608
|
+
const stop = chatStop(this.finish, this.tools.size > 0, this.stops);
|
|
579
609
|
out.push(this.event("message_delta", {
|
|
580
610
|
type: "message_delta",
|
|
581
|
-
delta: { stop_reason: reason, stop_sequence:
|
|
611
|
+
delta: { stop_reason: stop.reason, stop_sequence: stop.sequence },
|
|
582
612
|
usage: { input_tokens: this.usage?.input ?? 0, output_tokens: this.usage?.output ?? 0 },
|
|
583
613
|
}));
|
|
584
614
|
out.push(this.event("message_stop", { type: "message_stop" }));
|
|
@@ -608,7 +638,8 @@ export function fromResponses(res, requestedModel) {
|
|
|
608
638
|
}
|
|
609
639
|
const usage = res.usage ?? {};
|
|
610
640
|
const incomplete = res.incomplete_details?.reason;
|
|
611
|
-
const
|
|
641
|
+
const refused = (Array.isArray(res.output) ? res.output : []).some((item) => item.type === "message" && Array.isArray(item.content) && item.content.some((c) => c.type === "refusal"));
|
|
642
|
+
const stop = responsesStop(incomplete, content.some((c) => c.type === "tool_use"), refused);
|
|
612
643
|
return {
|
|
613
644
|
id: String(res.id ?? `msg_${crypto.randomBytes(8).toString("hex")}`),
|
|
614
645
|
type: "message",
|
|
@@ -638,6 +669,7 @@ export class ResponsesStreamTranslator {
|
|
|
638
669
|
usage = null;
|
|
639
670
|
incomplete = null;
|
|
640
671
|
sawTool = false;
|
|
672
|
+
sawRefusal = false;
|
|
641
673
|
id = `msg_${crypto.randomBytes(8).toString("hex")}`;
|
|
642
674
|
model;
|
|
643
675
|
constructor(model) {
|
|
@@ -700,6 +732,8 @@ export class ResponsesStreamTranslator {
|
|
|
700
732
|
if (type === "response.output_text.delta" || type === "response.refusal.delta") {
|
|
701
733
|
const entry = this.blocks.get(Number(ev.output_index));
|
|
702
734
|
const text = String(ev.delta ?? "");
|
|
735
|
+
if (type === "response.refusal.delta")
|
|
736
|
+
this.sawRefusal = true;
|
|
703
737
|
if (entry && entry.kind === "text" && text) {
|
|
704
738
|
out.push(this.event("content_block_delta", { type: "content_block_delta", index: entry.block, delta: { type: "text_delta", text } }));
|
|
705
739
|
}
|
|
@@ -752,7 +786,7 @@ export class ResponsesStreamTranslator {
|
|
|
752
786
|
}
|
|
753
787
|
out.push(this.event("content_block_stop", { type: "content_block_stop", index: entry.block }));
|
|
754
788
|
}
|
|
755
|
-
const reason = this.
|
|
789
|
+
const reason = responsesStop(this.incomplete, this.sawTool, this.sawRefusal);
|
|
756
790
|
out.push(this.event("message_delta", {
|
|
757
791
|
type: "message_delta",
|
|
758
792
|
delta: { stop_reason: reason, stop_sequence: null },
|
|
@@ -873,6 +907,18 @@ export async function startTranslator(spec) {
|
|
|
873
907
|
const text = await upstream.text();
|
|
874
908
|
const message = upstreamMessage(text, upstream.status);
|
|
875
909
|
log.push(`POST ${label} → ${upstream.status} (${Date.now() - started}ms): ${message.slice(0, 160)}`);
|
|
910
|
+
// The refusal in words, from the headers the response already
|
|
911
|
+
// carried. Only on a refusal, and only the allow-listed headers:
|
|
912
|
+
// a 400 about a malformed body is the work's problem, not the
|
|
913
|
+
// supply's, and explaining it as one would mislead.
|
|
914
|
+
if (isRefusalStatus(upstream.status)) {
|
|
915
|
+
log.push(refusalLine({
|
|
916
|
+
status: upstream.status,
|
|
917
|
+
headers: captureRefusalHeaders(upstream.headers),
|
|
918
|
+
timezone: spec.timezone,
|
|
919
|
+
provider: label,
|
|
920
|
+
}));
|
|
921
|
+
}
|
|
876
922
|
// The status crosses unchanged, so a 401 or 429 from the provider
|
|
877
923
|
// reads as one to the SDK — and to the fallback decision upstream.
|
|
878
924
|
return send(upstream.status, errorBody(upstream.status, message));
|
|
@@ -888,10 +934,10 @@ export async function startTranslator(spec) {
|
|
|
888
934
|
return send(502, errorBody(502, `translator: ${label} answered with something that is not JSON`));
|
|
889
935
|
}
|
|
890
936
|
log.push(`POST ${label} → 200 (${Date.now() - started}ms, ${model})`);
|
|
891
|
-
return send(200, JSON.stringify(responses ? fromResponses(parsed, model) : fromChatCompletion(parsed, model)));
|
|
937
|
+
return send(200, JSON.stringify(responses ? fromResponses(parsed, model) : fromChatCompletion(parsed, model, body.stop_sequences)));
|
|
892
938
|
}
|
|
893
939
|
res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache", connection: "keep-alive" });
|
|
894
|
-
const machine = responses ? new ResponsesStreamTranslator(model) : new StreamTranslator(model);
|
|
940
|
+
const machine = responses ? new ResponsesStreamTranslator(model) : new StreamTranslator(model, body.stop_sequences);
|
|
895
941
|
const reader = upstream.body?.getReader();
|
|
896
942
|
if (!reader) {
|
|
897
943
|
for (const e of machine.finishStream())
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/** What became of one trigger firing. `started` is here too: without it the
|
|
2
2
|
* log answers "why did nothing run" and not "how often does anything",
|
|
3
3
|
* and the ratio is the whole point of the page. */
|
|
4
|
-
export type TriggerOutcome = "started" | "duplicate" | "throttled" | "debounced" | "quarantined" | "overlap-skipped" | "missed" | "failed";
|
|
4
|
+
export type TriggerOutcome = "started" | "duplicate" | "throttled" | "debounced" | "quarantined" | "paused" | "overlap-skipped" | "missed" | "failed";
|
|
5
5
|
export interface TriggerEvent {
|
|
6
6
|
t: string;
|
|
7
7
|
flow: string;
|
package/dist/src/triggers.d.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { type FlowInfo, type FlowStep, type RunRecord } from "./store.ts";
|
|
2
2
|
/** Staple a payload to the first step, the way the run route staples a task. */
|
|
3
3
|
export declare function withTask(steps: FlowStep[], tag: string, body: string): FlowStep[];
|
|
4
|
+
/** Which `on:` a settled run answers to: failed, blocked (completed, but
|
|
5
|
+
* its work refused itself), or completed. */
|
|
6
|
+
export declare function endingOf(run: RunRecord): "completed" | "failed" | "blocked";
|
|
4
7
|
/** The flows a finished run should start, by this workspace's own files. */
|
|
5
8
|
export declare function chainedFlows(tenant: string, workspace: string, finished: RunRecord): FlowInfo[];
|
|
6
9
|
/**
|
package/dist/src/triggers.js
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
// an agent reads it — the same rule as every other handoff in the format.
|
|
14
14
|
import crypto from "node:crypto";
|
|
15
15
|
import { platform } from "./platform.js";
|
|
16
|
-
import { listFlows, readRun } from "./store.js";
|
|
16
|
+
import { listFlows, readRun, runVerdict } from "./store.js";
|
|
17
17
|
import { getSecret } from "./secrets.js";
|
|
18
18
|
/** The last step's result — the same reading runner.ts makes, kept local so
|
|
19
19
|
* this module never imports the runner (the queue imports both). */
|
|
@@ -33,6 +33,13 @@ export function withTask(steps, tag, body) {
|
|
|
33
33
|
return steps.map((s, i) => (i === 0 ? { ...s, instruction: `${s.instruction}\n\n<${tag}>\n${text}\n</${tag}>` } : s));
|
|
34
34
|
}
|
|
35
35
|
// ------------------------------------------------------------ trigger: flow
|
|
36
|
+
/** Which `on:` a settled run answers to: failed, blocked (completed, but
|
|
37
|
+
* its work refused itself), or completed. */
|
|
38
|
+
export function endingOf(run) {
|
|
39
|
+
if (run.status === "failed")
|
|
40
|
+
return "failed";
|
|
41
|
+
return runVerdict(run) === "BLOCKED" ? "blocked" : "completed";
|
|
42
|
+
}
|
|
36
43
|
/** The flows a finished run should start, by this workspace's own files. */
|
|
37
44
|
export function chainedFlows(tenant, workspace, finished) {
|
|
38
45
|
if (finished.status !== "completed" && finished.status !== "failed")
|
|
@@ -45,7 +52,7 @@ export function chainedFlows(tenant, workspace, finished) {
|
|
|
45
52
|
// A flow chained on itself is a loop with no bound — refused here and
|
|
46
53
|
// by foldrun check, never started.
|
|
47
54
|
f.name !== finished.flow &&
|
|
48
|
-
(f.on === "any" || f.on === finished
|
|
55
|
+
(f.on === "any" || f.on === endingOf(finished)));
|
|
49
56
|
}
|
|
50
57
|
/**
|
|
51
58
|
* Start every flow chained on a run that just settled. The finished run's
|
|
@@ -61,6 +68,7 @@ export async function fireChainedFlows(tenant, workspace, finished) {
|
|
|
61
68
|
const started = [];
|
|
62
69
|
const result = (runResult(finished) ?? "").slice(0, 20_000);
|
|
63
70
|
const body = `run: ${finished.id}\nflow: ${finished.flow}\nstatus: ${finished.status}\n` +
|
|
71
|
+
(runVerdict(finished) ? `verdict: ${runVerdict(finished)}\n` : "") +
|
|
64
72
|
(finished.summary ? `summary: ${finished.summary}\n` : "") +
|
|
65
73
|
(result ? `\n${result}` : "");
|
|
66
74
|
for (const flow of flows) {
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** How many redirects one request may follow. fetch's own limit is 20;
|
|
2
|
+
* nothing a tenant points the platform at needs more than a couple. */
|
|
3
|
+
export declare const MAX_REDIRECTS = 5;
|
|
4
|
+
export declare class RedirectRefused extends Error {
|
|
5
|
+
constructor(message: string);
|
|
6
|
+
}
|
|
7
|
+
export declare function fetchUntrusted(url: string, init?: RequestInit, opts?: {
|
|
8
|
+
/** The request carries a credential (in its body or its headers):
|
|
9
|
+
* follow a redirect only within the origin it was sent to. */
|
|
10
|
+
sameOrigin?: boolean;
|
|
11
|
+
}): Promise<Response>;
|
|
@@ -0,0 +1,77 @@
|
|
|
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
|
+
import { platform } from "./platform.js";
|
|
18
|
+
/** How many redirects one request may follow. fetch's own limit is 20;
|
|
19
|
+
* nothing a tenant points the platform at needs more than a couple. */
|
|
20
|
+
export const MAX_REDIRECTS = 5;
|
|
21
|
+
const REDIRECT = new Set([301, 302, 303, 307, 308]);
|
|
22
|
+
/** Headers that may cross to another origin: none of them can carry a
|
|
23
|
+
* credential. Everything else — Authorization, a cookie, an x-api-key, a
|
|
24
|
+
* webhook's signature — stays with the origin it was meant for. */
|
|
25
|
+
const CROSS_ORIGIN_SAFE = new Set(["accept", "accept-language", "content-type", "user-agent"]);
|
|
26
|
+
export class RedirectRefused extends Error {
|
|
27
|
+
constructor(message) {
|
|
28
|
+
super(message);
|
|
29
|
+
this.name = "RedirectRefused";
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
export async function fetchUntrusted(url, init = {}, opts = {}) {
|
|
33
|
+
let current = url;
|
|
34
|
+
let req = { ...init };
|
|
35
|
+
for (let hop = 0;; hop++) {
|
|
36
|
+
const res = await platform.fetchUntrusted(current, { ...req, redirect: "manual" });
|
|
37
|
+
const location = REDIRECT.has(res.status) ? res.headers.get("location") : null;
|
|
38
|
+
if (!location)
|
|
39
|
+
return res;
|
|
40
|
+
// The body of a redirect is never read; free the socket.
|
|
41
|
+
await res.body?.cancel().catch(() => { });
|
|
42
|
+
if (hop >= MAX_REDIRECTS)
|
|
43
|
+
throw new RedirectRefused(`${url}: more than ${MAX_REDIRECTS} redirects — not followed`);
|
|
44
|
+
let next;
|
|
45
|
+
try {
|
|
46
|
+
next = new URL(location, current);
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
throw new RedirectRefused(`${current} redirected to "${location}", which is not a URL`);
|
|
50
|
+
}
|
|
51
|
+
if (next.protocol !== "https:" && next.protocol !== "http:") {
|
|
52
|
+
throw new RedirectRefused(`${current} redirected to a ${next.protocol} URL — not followed`);
|
|
53
|
+
}
|
|
54
|
+
const from = new URL(current);
|
|
55
|
+
const crossOrigin = next.origin !== from.origin;
|
|
56
|
+
if (crossOrigin && opts.sameOrigin) {
|
|
57
|
+
throw new RedirectRefused(`${from.origin} redirected to ${next.origin} — not followed: this request carries a credential, and it goes only where it was sent`);
|
|
58
|
+
}
|
|
59
|
+
// fetch's own rewrite: a 303, and a 301/302 answering a POST, is
|
|
60
|
+
// followed as a GET with no body; a 307/308 repeats the request as it was.
|
|
61
|
+
const method = (req.method ?? "GET").toUpperCase();
|
|
62
|
+
const headers = new Headers(req.headers);
|
|
63
|
+
if ((res.status === 303 && method !== "GET" && method !== "HEAD") || ((res.status === 301 || res.status === 302) && method === "POST")) {
|
|
64
|
+
req = { ...req, method: "GET", body: undefined };
|
|
65
|
+
headers.delete("content-type");
|
|
66
|
+
}
|
|
67
|
+
if (crossOrigin) {
|
|
68
|
+
const names = [];
|
|
69
|
+
headers.forEach((_, name) => names.push(name));
|
|
70
|
+
for (const name of names)
|
|
71
|
+
if (!CROSS_ORIGIN_SAFE.has(name))
|
|
72
|
+
headers.delete(name);
|
|
73
|
+
}
|
|
74
|
+
req = { ...req, headers };
|
|
75
|
+
current = next.toString();
|
|
76
|
+
}
|
|
77
|
+
}
|