@flue/sdk 0.3.4 → 0.3.6
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 +5 -3
- package/dist/{agent-BB4lwAd5.mjs → agent-BTB0809P.mjs} +1 -1
- package/dist/client.d.mts +2 -2
- package/dist/client.mjs +3 -3
- package/dist/cloudflare/index.d.mts +2 -2
- package/dist/cloudflare/index.mjs +5 -3
- package/dist/{command-helpers-DdAfbnom.d.mts → command-helpers-CXzopT_-.d.mts} +1 -1
- package/dist/index.d.mts +1 -1
- package/dist/index.mjs +222 -92
- package/dist/internal.d.mts +264 -3
- package/dist/internal.mjs +420 -4
- package/dist/{mcp-DOgMtp8y.mjs → mcp-DTFRe9vh.mjs} +4 -3
- package/dist/{mcp-BVF-sOBZ.d.mts → mcp-EZy-Vb5M.d.mts} +1 -1
- package/dist/node/index.d.mts +2 -2
- package/dist/sandbox.d.mts +2 -1
- package/dist/sandbox.mjs +31 -5
- package/dist/{session-DukL3zwF.mjs → session-BaaSQTWS.mjs} +6 -4
- package/dist/{types-T8pE1xIS.d.mts → types-CItTrBsU.d.mts} +9 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -4,11 +4,13 @@
|
|
|
4
4
|
|
|
5
5
|
# Flue
|
|
6
6
|
|
|
7
|
-
Flue is **The
|
|
7
|
+
Flue is **The Agent Harness Framework.** If you know how to use Claude Code (or OpenCode, Codex, Gemini, etc)... then you already know the basics of how to build agents with Flue.
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
Flue is a TypeScript framework for building the next generation of agents, designed around a built-in **agent harness**. It's like Claude Code, but 100% headless and programmable. There's no baked-in assumption like requiring a human operator to function. No TUI. No GUI. Just TypeScript.
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
But using Flue feels like using Claude Code. The agents you build act autonomously to solve problems and complete tasks. They require very little code to run — most of the "logic" lives in Markdown: skills, context, and `AGENTS.md`.
|
|
12
|
+
|
|
13
|
+
Flue isn't another AI SDK. It's a proper runtime-agnostic framework — think Astro or Next.js, but for agents. Write once, build, and deploy your agents anywhere (Node.js, Cloudflare, GitHub Actions, GitLab CI/CD, etc).
|
|
12
14
|
|
|
13
15
|
## Packages
|
|
14
16
|
|
|
@@ -287,7 +287,7 @@ function createBashTool(env) {
|
|
|
287
287
|
}),
|
|
288
288
|
async execute(_toolCallId, params, signal) {
|
|
289
289
|
throwIfAborted(signal);
|
|
290
|
-
return formatBashResult(await env.exec(params.command), params.command);
|
|
290
|
+
return formatBashResult(await env.exec(params.command, { timeout: params.timeout }), params.command);
|
|
291
291
|
}
|
|
292
292
|
};
|
|
293
293
|
}
|
package/dist/client.d.mts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { A as TaskOptions, C as SessionEnv, D as ShellResult, E as ShellOptions, M as ToolParameters, S as SessionData, T as SessionStore, _ as FlueSessions, a as BashLike, d as FileStat, f as FlueAgent, g as FlueSession, h as FlueEventCallback, i as BashFactory, j as ToolDef, k as SkillOptions, l as Command, m as FlueEvent, p as FlueContext, r as AgentInit, t as AgentConfig, v as PromptOptions, w as SessionOptions, x as SandboxFactory, y as PromptResponse } from "./types-
|
|
2
|
-
import { i as connectMcpServer, n as McpServerOptions, r as McpTransport, t as McpServerConnection } from "./mcp-
|
|
1
|
+
import { A as TaskOptions, C as SessionEnv, D as ShellResult, E as ShellOptions, M as ToolParameters, S as SessionData, T as SessionStore, _ as FlueSessions, a as BashLike, d as FileStat, f as FlueAgent, g as FlueSession, h as FlueEventCallback, i as BashFactory, j as ToolDef, k as SkillOptions, l as Command, m as FlueEvent, p as FlueContext, r as AgentInit, t as AgentConfig, v as PromptOptions, w as SessionOptions, x as SandboxFactory, y as PromptResponse } from "./types-CItTrBsU.mjs";
|
|
2
|
+
import { i as connectMcpServer, n as McpServerOptions, r as McpTransport, t as McpServerConnection } from "./mcp-EZy-Vb5M.mjs";
|
|
3
3
|
import { Type } from "@mariozechner/pi-ai";
|
|
4
4
|
|
|
5
5
|
//#region src/client.d.ts
|
package/dist/client.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { r as discoverSessionContext } from "./agent-
|
|
2
|
-
import { a as assertRoleExists } from "./session-
|
|
1
|
+
import { r as discoverSessionContext } from "./agent-BTB0809P.mjs";
|
|
2
|
+
import { a as assertRoleExists } from "./session-BaaSQTWS.mjs";
|
|
3
3
|
import { bashFactoryToSessionEnv, createCwdSessionEnv } from "./sandbox.mjs";
|
|
4
|
-
import { n as AgentClient, t as connectMcpServer } from "./mcp-
|
|
4
|
+
import { n as AgentClient, t as connectMcpServer } from "./mcp-DTFRe9vh.mjs";
|
|
5
5
|
import { Type } from "@mariozechner/pi-ai";
|
|
6
6
|
|
|
7
7
|
//#region src/client.ts
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { C as SessionEnv, T as SessionStore, l as Command } from "../types-
|
|
2
|
-
import { t as CommandExecutor } from "../command-helpers-
|
|
1
|
+
import { C as SessionEnv, T as SessionStore, l as Command } from "../types-CItTrBsU.mjs";
|
|
2
|
+
import { t as CommandExecutor } from "../command-helpers-CXzopT_-.mjs";
|
|
3
3
|
|
|
4
4
|
//#region src/cloudflare/virtual-sandbox.d.ts
|
|
5
5
|
interface VirtualSandboxOptions {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import "../agent-
|
|
2
|
-
import "../session-
|
|
1
|
+
import "../agent-BTB0809P.mjs";
|
|
2
|
+
import "../session-BaaSQTWS.mjs";
|
|
3
3
|
import { createSandboxSessionEnv } from "../sandbox.mjs";
|
|
4
4
|
import { t as normalizeExecutor } from "../command-helpers-hTZKWK13.mjs";
|
|
5
5
|
import { Workspace, WorkspaceFileSystem } from "@cloudflare/shell";
|
|
@@ -195,9 +195,11 @@ async function cfSandboxToSessionEnv(sandbox, cwd = "/workspace") {
|
|
|
195
195
|
} else await sandbox.deleteFile(path);
|
|
196
196
|
},
|
|
197
197
|
async exec(command, execOpts) {
|
|
198
|
+
const timeoutMs = typeof execOpts?.timeout === "number" ? execOpts.timeout * 1e3 : void 0;
|
|
198
199
|
const result = await sandbox.exec(command, {
|
|
199
200
|
cwd: execOpts?.cwd,
|
|
200
|
-
env: execOpts?.env
|
|
201
|
+
env: execOpts?.env,
|
|
202
|
+
timeout: timeoutMs
|
|
201
203
|
});
|
|
202
204
|
return {
|
|
203
205
|
stdout: result.stdout ?? "",
|
package/dist/index.d.mts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { A as TaskOptions, C as SessionEnv, D as ShellResult, E as ShellOptions, M as ToolParameters, O as Skill, S as SessionData, T as SessionStore, _ as FlueSessions, a as BashLike, b as Role, c as BuildPlugin, d as FileStat, f as FlueAgent, g as FlueSession, h as FlueEventCallback, i as BashFactory, j as ToolDef, k as SkillOptions, l as Command, m as FlueEvent, n as AgentInfo, o as BuildContext, p as FlueContext, r as AgentInit, s as BuildOptions, t as AgentConfig, u as CommandDef, v as PromptOptions, w as SessionOptions, x as SandboxFactory, y as PromptResponse } from "./types-
|
|
1
|
+
import { A as TaskOptions, C as SessionEnv, D as ShellResult, E as ShellOptions, M as ToolParameters, O as Skill, S as SessionData, T as SessionStore, _ as FlueSessions, a as BashLike, b as Role, c as BuildPlugin, d as FileStat, f as FlueAgent, g as FlueSession, h as FlueEventCallback, i as BashFactory, j as ToolDef, k as SkillOptions, l as Command, m as FlueEvent, n as AgentInfo, o as BuildContext, p as FlueContext, r as AgentInit, s as BuildOptions, t as AgentConfig, u as CommandDef, v as PromptOptions, w as SessionOptions, x as SandboxFactory, y as PromptResponse } from "./types-CItTrBsU.mjs";
|
|
2
2
|
import { AgentTool, AgentToolResult } from "@mariozechner/pi-agent-core";
|
|
3
3
|
|
|
4
4
|
//#region src/build.d.ts
|
package/dist/index.mjs
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import { a as parseFrontmatterFile, n as createTools, t as BUILTIN_TOOL_NAMES } from "./agent-
|
|
1
|
+
import { a as parseFrontmatterFile, n as createTools, t as BUILTIN_TOOL_NAMES } from "./agent-BTB0809P.mjs";
|
|
2
2
|
import * as esbuild from "esbuild";
|
|
3
3
|
import * as fs from "node:fs";
|
|
4
4
|
import * as path from "node:path";
|
|
5
5
|
import { packageUpSync } from "package-up";
|
|
6
6
|
import { spawn } from "node:child_process";
|
|
7
|
+
import { randomUUID } from "node:crypto";
|
|
7
8
|
import { parseEnv } from "node:util";
|
|
8
9
|
|
|
9
10
|
//#region src/cloudflare-wrangler-merge.ts
|
|
@@ -267,11 +268,11 @@ function stripNoisyWranglerDefaults(merged) {
|
|
|
267
268
|
}
|
|
268
269
|
/**
|
|
269
270
|
* Return the list of `class_name`s declared in the user's wrangler
|
|
270
|
-
* `durable_objects.bindings` that
|
|
271
|
+
* `durable_objects.bindings` that end with the literal suffix `Sandbox`
|
|
271
272
|
* (case-sensitive).
|
|
272
273
|
*
|
|
273
274
|
* This is Flue's convention for wiring `@cloudflare/sandbox`: any DO binding
|
|
274
|
-
* whose class name
|
|
275
|
+
* whose class name ends with `Sandbox` triggers an automatic re-export in the
|
|
275
276
|
* generated Worker entry:
|
|
276
277
|
*
|
|
277
278
|
* export { Sandbox as <class_name> } from '@cloudflare/sandbox';
|
|
@@ -281,6 +282,13 @@ function stripNoisyWranglerDefaults(merged) {
|
|
|
281
282
|
* `@cloudflare/sandbox` package. Each distinct `class_name` can be paired with
|
|
282
283
|
* a different container image in the user's `containers[]` config.
|
|
283
284
|
*
|
|
285
|
+
* The match is intentionally a suffix (not substring) so that user-defined
|
|
286
|
+
* classes whose names merely contain "Sandbox" mid-word — e.g. `MySandboxV2`,
|
|
287
|
+
* `MySandboxedAgent`, `LegacySandboxedThing` — are not silently overridden
|
|
288
|
+
* by the `@cloudflare/sandbox` re-export. Note that classes whose names
|
|
289
|
+
* still end in `Sandbox` (e.g. `MockSandbox`, `NotASandbox`) will match;
|
|
290
|
+
* to opt out, rename the class to not end in `Sandbox`.
|
|
291
|
+
*
|
|
284
292
|
* Returns unique, sorted class names. Non-object bindings or bindings without
|
|
285
293
|
* a string `class_name` are ignored.
|
|
286
294
|
*/
|
|
@@ -294,7 +302,7 @@ function detectSandboxBindings(userConfig) {
|
|
|
294
302
|
if (typeof entry !== "object" || entry === null) continue;
|
|
295
303
|
const className = entry.class_name;
|
|
296
304
|
if (typeof className !== "string") continue;
|
|
297
|
-
if (className.
|
|
305
|
+
if (className.endsWith("Sandbox")) found.add(className);
|
|
298
306
|
}
|
|
299
307
|
return Array.from(found).sort();
|
|
300
308
|
}
|
|
@@ -328,7 +336,7 @@ function assertSandboxPackageInstalled(sandboxClassNames, searchDirs) {
|
|
|
328
336
|
current = path.dirname(current);
|
|
329
337
|
}
|
|
330
338
|
}
|
|
331
|
-
throw new Error(`[flue] Your wrangler config declares DO binding(s) whose class_name
|
|
339
|
+
throw new Error(`[flue] Your wrangler config declares DO binding(s) whose class_name ends with "Sandbox" (${sandboxClassNames.join(", ")}), but @cloudflare/sandbox is not in your package.json. Install it: \`npm install @cloudflare/sandbox\`.`);
|
|
332
340
|
}
|
|
333
341
|
/**
|
|
334
342
|
* Write the wrangler deploy-redirect file at `<outputDir>/.wrangler/deploy/config.json`
|
|
@@ -396,16 +404,23 @@ var CloudflarePlugin = class {
|
|
|
396
404
|
}`;
|
|
397
405
|
}).join("\n\n");
|
|
398
406
|
const { config: userConfig } = await this.getUserConfig(ctx.outputDir);
|
|
407
|
+
const sandboxReExports = detectSandboxBindings(userConfig).map((name) => `export { Sandbox as ${name} } from '@cloudflare/sandbox';`).join("\n");
|
|
399
408
|
return `
|
|
400
409
|
// Auto-generated by @flue/sdk build (cloudflare)
|
|
401
410
|
import { Agent, routeAgentRequest } from 'agents';
|
|
402
|
-
import { DurableObject } from 'cloudflare:workers';
|
|
403
411
|
import { Bash, InMemoryFs } from 'just-bash';
|
|
404
412
|
import {
|
|
405
413
|
createFlueContext,
|
|
406
414
|
InMemorySessionStore,
|
|
407
415
|
bashFactoryToSessionEnv,
|
|
408
416
|
resolveModel,
|
|
417
|
+
parseJsonBody,
|
|
418
|
+
toHttpResponse,
|
|
419
|
+
toSseData,
|
|
420
|
+
AgentNotFoundError,
|
|
421
|
+
MethodNotAllowedError,
|
|
422
|
+
RouteNotFoundError,
|
|
423
|
+
InvalidRequestError,
|
|
409
424
|
} from '@flue/sdk/internal';
|
|
410
425
|
import { runWithCloudflareContext, cfSandboxToSessionEnv } from '@flue/sdk/cloudflare';
|
|
411
426
|
|
|
@@ -418,6 +433,12 @@ const skills = {};
|
|
|
418
433
|
const systemPrompt = '';
|
|
419
434
|
const manifest = ${manifest};
|
|
420
435
|
|
|
436
|
+
// Set of webhook-accessible agent names (raw form, as used in URL segments).
|
|
437
|
+
// Used by the worker fetch handler to pre-route requests and reject unknown
|
|
438
|
+
// agents with a JSON 404 envelope before the request hits the partyserver
|
|
439
|
+
// dispatcher (which would otherwise return text/plain "Invalid request").
|
|
440
|
+
const webhookAgentNames = new Set(${JSON.stringify(webhookAgents.map((a) => a.name))});
|
|
441
|
+
|
|
421
442
|
// ─── Infrastructure ─────────────────────────────────────────────────────────
|
|
422
443
|
|
|
423
444
|
// No build-time model default. The user sets model at runtime via
|
|
@@ -456,24 +477,34 @@ async function createLocalEnv() {
|
|
|
456
477
|
* RPC stub, null otherwise.
|
|
457
478
|
*
|
|
458
479
|
* NOTE on detection: The value returned by \`getSandbox()\` is a workerd RPC
|
|
459
|
-
* Proxy.
|
|
460
|
-
* property name, so structural duck-typing is unreliable.
|
|
480
|
+
* Proxy. None of the obvious detection strategies work:
|
|
461
481
|
*
|
|
462
|
-
*
|
|
463
|
-
*
|
|
464
|
-
*
|
|
465
|
-
*
|
|
466
|
-
*
|
|
467
|
-
*
|
|
482
|
+
* - Structural duck-typing (\`'X' in stub\`, \`typeof stub.X === 'function'\`):
|
|
483
|
+
* the proxy lies positively for any property name, so any check returns
|
|
484
|
+
* \`true\` regardless of what's actually on the remote.
|
|
485
|
+
* - \`instanceof <UserSandboxClass>\` (e.g. \`Sandbox\` from
|
|
486
|
+
* \`@cloudflare/sandbox\`): the user's class only exists on the in-DO
|
|
487
|
+
* side; over RPC the caller gets a generic stub.
|
|
488
|
+
* - \`instanceof DurableObject\` (imported from \`cloudflare:workers\`): the
|
|
489
|
+
* stub's prototype chain has a class *named* \`DurableObject\`, but it's a
|
|
490
|
+
* workerd-internal class with a different identity than the importable
|
|
491
|
+
* one. \`instanceof\` checks identity, not name, so it returns \`false\`.
|
|
468
492
|
*
|
|
469
|
-
*
|
|
470
|
-
*
|
|
471
|
-
*
|
|
472
|
-
*
|
|
473
|
-
*
|
|
493
|
+
* The one signal that does work — verified by runtime probe — is the string
|
|
494
|
+
* name of the prototype's constructor. Workerd's internal RPC stub class is
|
|
495
|
+
* named \`DurableObject\`, and \`Object.getPrototypeOf(stub).constructor.name\`
|
|
496
|
+
* returns that string. This is a heuristic (it relies on a workerd-internal
|
|
497
|
+
* naming convention, not a contractual API), but it's empirically correct
|
|
498
|
+
* today and will misroute only if a user passes some other DO stub to
|
|
499
|
+
* \`init({ sandbox })\` — in which case \`cfSandboxToSessionEnv\` will fail
|
|
500
|
+
* loudly on first method call.
|
|
474
501
|
*/
|
|
475
502
|
function resolveSandbox(sandbox) {
|
|
476
|
-
if (
|
|
503
|
+
if (
|
|
504
|
+
sandbox &&
|
|
505
|
+
typeof sandbox === 'object' &&
|
|
506
|
+
Object.getPrototypeOf(sandbox)?.constructor?.name === 'DurableObject'
|
|
507
|
+
) {
|
|
477
508
|
return cfSandboxToSessionEnv(sandbox);
|
|
478
509
|
}
|
|
479
510
|
return null;
|
|
@@ -588,19 +619,16 @@ async function handleAgentRequest(request, doInstance, agentName, handler) {
|
|
|
588
619
|
// Agent id is the DO "room name" set by routeAgentRequest
|
|
589
620
|
const id = doInstance.name;
|
|
590
621
|
|
|
591
|
-
// Parse payload
|
|
592
|
-
let payload;
|
|
593
622
|
try {
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
623
|
+
// Parse the request body. Throws on invalid Content-Type or malformed
|
|
624
|
+
// JSON; returns {} for genuinely empty bodies (so no-payload agents
|
|
625
|
+
// still work).
|
|
626
|
+
const payload = await parseJsonBody(request);
|
|
598
627
|
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
628
|
+
const accept = request.headers.get('accept') || '';
|
|
629
|
+
const isWebhook = request.headers.get('x-webhook') === 'true';
|
|
630
|
+
const isSSE = accept.includes('text/event-stream') && !isWebhook;
|
|
602
631
|
|
|
603
|
-
try {
|
|
604
632
|
// Fire-and-forget (webhook mode)
|
|
605
633
|
if (isWebhook) {
|
|
606
634
|
const requestId = crypto.randomUUID();
|
|
@@ -619,7 +647,13 @@ async function handleAgentRequest(request, doInstance, agentName, handler) {
|
|
|
619
647
|
});
|
|
620
648
|
}
|
|
621
649
|
|
|
622
|
-
// SSE streaming mode
|
|
650
|
+
// SSE streaming mode. Two error regimes meet here:
|
|
651
|
+
// - Pre-stream errors (body parsing, etc.) have already thrown above
|
|
652
|
+
// and are caught by the outer try/catch — rendered as plain HTTP
|
|
653
|
+
// responses by toHttpResponse, since headers haven't been sent yet.
|
|
654
|
+
// - Errors during agent execution surface as in-stream \`error\`
|
|
655
|
+
// events with the canonical envelope (via toSseData), since by
|
|
656
|
+
// then the 200 + text/event-stream headers are already on the wire.
|
|
623
657
|
if (isSSE) {
|
|
624
658
|
const { readable, writable } = new TransformStream();
|
|
625
659
|
const writer = writable.getWriter();
|
|
@@ -631,7 +665,7 @@ async function handleAgentRequest(request, doInstance, agentName, handler) {
|
|
|
631
665
|
const lines = [];
|
|
632
666
|
if (event) lines.push('event: ' + event);
|
|
633
667
|
lines.push('id: ' + eventId++);
|
|
634
|
-
lines.push('data: ' + JSON.stringify(data));
|
|
668
|
+
lines.push('data: ' + (typeof data === 'string' ? data : JSON.stringify(data)));
|
|
635
669
|
lines.push('', '');
|
|
636
670
|
await writer.write(encoder.encode(lines.join('\\n')));
|
|
637
671
|
};
|
|
@@ -653,10 +687,7 @@ async function handleAgentRequest(request, doInstance, agentName, handler) {
|
|
|
653
687
|
'result',
|
|
654
688
|
);
|
|
655
689
|
} catch (err) {
|
|
656
|
-
await writeSSE(
|
|
657
|
-
{ type: 'error', error: String(err) },
|
|
658
|
-
'error',
|
|
659
|
-
);
|
|
690
|
+
await writeSSE(toSseData(err), 'error');
|
|
660
691
|
if (!isIdle) {
|
|
661
692
|
await writeSSE({ type: 'idle' }, 'idle');
|
|
662
693
|
}
|
|
@@ -687,11 +718,10 @@ async function handleAgentRequest(request, doInstance, agentName, handler) {
|
|
|
687
718
|
ctx.setEventCallback(undefined);
|
|
688
719
|
}
|
|
689
720
|
} catch (err) {
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
);
|
|
721
|
+
// toHttpResponse logs unknowns via flueLog.error — no extra console.error
|
|
722
|
+
// needed here. The agentName tag is captured in the wrapped error's
|
|
723
|
+
// server-side log line via flueLog's prefix.
|
|
724
|
+
return toHttpResponse(err);
|
|
695
725
|
}
|
|
696
726
|
}
|
|
697
727
|
|
|
@@ -701,38 +731,77 @@ ${agentClasses}
|
|
|
701
731
|
|
|
702
732
|
// ─── User-declared Sandbox re-exports ──────────────────────────────────────
|
|
703
733
|
// One line per DO binding in the user's wrangler.jsonc whose class_name
|
|
704
|
-
//
|
|
734
|
+
// ends with "Sandbox". Flue aliases the single \`Sandbox\` class shipped by
|
|
705
735
|
// \`@cloudflare/sandbox\` so each user-chosen class_name resolves at the
|
|
706
736
|
// bundle's top level. The binding + container image configuration is owned
|
|
707
737
|
// by the user's wrangler.jsonc.
|
|
708
|
-
${
|
|
738
|
+
${sandboxReExports}
|
|
709
739
|
|
|
710
740
|
// ─── Worker Fetch Handler ───────────────────────────────────────────────────
|
|
711
741
|
|
|
712
742
|
export default {
|
|
713
743
|
async fetch(request, env) {
|
|
714
|
-
|
|
744
|
+
try {
|
|
745
|
+
const url = new URL(request.url);
|
|
746
|
+
const method = request.method;
|
|
715
747
|
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
748
|
+
// Health check
|
|
749
|
+
if (url.pathname === '/health') {
|
|
750
|
+
return new Response(JSON.stringify({ status: 'ok' }), {
|
|
751
|
+
headers: { 'content-type': 'application/json' },
|
|
752
|
+
});
|
|
753
|
+
}
|
|
722
754
|
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
755
|
+
// Agent manifest
|
|
756
|
+
if (url.pathname === '/agents' && method === 'GET') {
|
|
757
|
+
return new Response(JSON.stringify(manifest), {
|
|
758
|
+
headers: { 'content-type': 'application/json' },
|
|
759
|
+
});
|
|
760
|
+
}
|
|
729
761
|
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
762
|
+
// Webhook agent route: /agents/<name>/<id>
|
|
763
|
+
//
|
|
764
|
+
// We pre-check method and agent registration here, BEFORE delegating to
|
|
765
|
+
// routeAgentRequest. Without this, partyserver (the transitive
|
|
766
|
+
// dispatcher behind routeAgentRequest) returns a text/plain
|
|
767
|
+
// "Invalid request" 400 for unknown agent namespaces — visibly
|
|
768
|
+
// inconsistent with the rest of the API and with the Node target.
|
|
769
|
+
// Pre-routing means every error path in the agent route flows through
|
|
770
|
+
// the same JSON envelope.
|
|
771
|
+
const agentRouteMatch = url.pathname.match(/^\\/agents\\/([^/]+)\\/([^/]+)\\/?$/);
|
|
772
|
+
if (agentRouteMatch) {
|
|
773
|
+
if (method !== 'POST') {
|
|
774
|
+
throw new MethodNotAllowedError({ method, allowed: ['POST'] });
|
|
775
|
+
}
|
|
776
|
+
const name = decodeURIComponent(agentRouteMatch[1]);
|
|
777
|
+
const id = decodeURIComponent(agentRouteMatch[2]);
|
|
778
|
+
if (name.trim() === '' || id.trim() === '') {
|
|
779
|
+
throw new InvalidRequestError({
|
|
780
|
+
reason: 'Webhook URLs must have the shape /agents/<name>/<id> with non-empty segments.',
|
|
781
|
+
});
|
|
782
|
+
}
|
|
783
|
+
if (!webhookAgentNames.has(name)) {
|
|
784
|
+
throw new AgentNotFoundError({
|
|
785
|
+
name,
|
|
786
|
+
available: Array.from(webhookAgentNames),
|
|
787
|
+
});
|
|
788
|
+
}
|
|
789
|
+
// All gating passed. Delegate to the Agents SDK / partyserver, which
|
|
790
|
+
// dispatches into the per-agent DO's onRequest → handleAgentRequest.
|
|
791
|
+
// routeAgentRequest may still return null for shape mismatches we
|
|
792
|
+
// didn't anticipate; treat that as a route_not_found with a hint.
|
|
793
|
+
const response = await routeAgentRequest(request, env);
|
|
794
|
+
if (response) return response;
|
|
795
|
+
throw new RouteNotFoundError({ method, path: url.pathname });
|
|
796
|
+
}
|
|
734
797
|
|
|
735
|
-
|
|
798
|
+
// Anything else: canonical 404 envelope.
|
|
799
|
+
throw new RouteNotFoundError({ method, path: url.pathname });
|
|
800
|
+
} catch (err) {
|
|
801
|
+
// toHttpResponse logs unknowns via flueLog.error — no extra
|
|
802
|
+
// console.error needed at this layer.
|
|
803
|
+
return toHttpResponse(err);
|
|
804
|
+
}
|
|
736
805
|
},
|
|
737
806
|
};
|
|
738
807
|
`;
|
|
@@ -757,7 +826,7 @@ export default {
|
|
|
757
826
|
const sandboxClassNames = detectSandboxBindings(userConfig);
|
|
758
827
|
if (sandboxClassNames.length > 0) {
|
|
759
828
|
assertSandboxPackageInstalled(sandboxClassNames, [ctx.outputDir, ctx.workspaceDir]);
|
|
760
|
-
for (const className of sandboxClassNames) console.log(`[flue]
|
|
829
|
+
for (const className of sandboxClassNames) console.log(`[flue] Auto-wiring DO binding "${className}" to @cloudflare/sandbox's Sandbox class.`);
|
|
761
830
|
}
|
|
762
831
|
const merged = mergeFlueAdditions(userConfig, additions);
|
|
763
832
|
stripNoisyWranglerDefaults(merged);
|
|
@@ -801,6 +870,11 @@ import {
|
|
|
801
870
|
InMemorySessionStore,
|
|
802
871
|
bashFactoryToSessionEnv,
|
|
803
872
|
resolveModel,
|
|
873
|
+
parseJsonBody,
|
|
874
|
+
validateAgentRequest,
|
|
875
|
+
toHttpResponse,
|
|
876
|
+
toSseData,
|
|
877
|
+
RouteNotFoundError,
|
|
804
878
|
} from '@flue/sdk/internal';
|
|
805
879
|
import { randomUUID } from 'node:crypto';
|
|
806
880
|
|
|
@@ -818,7 +892,10 @@ const handlers = {
|
|
|
818
892
|
${agents.map((a) => ` ${JSON.stringify(a.name)}: ${agentVarName(a.name)},`).join("\n")}
|
|
819
893
|
};
|
|
820
894
|
|
|
821
|
-
|
|
895
|
+
// Set of webhook-accessible agent names. Named distinctly from the
|
|
896
|
+
// validateAgentRequest \`webhookAgents\` parameter to keep the call site
|
|
897
|
+
// below readable.
|
|
898
|
+
const webhookAgentSet = new Set(${JSON.stringify(webhookAgents.map((a) => a.name))});
|
|
822
899
|
|
|
823
900
|
// When the CLI starts this server via \`flue run\`, it sets FLUE_MODE=local.
|
|
824
901
|
// In local mode the HTTP route accepts any registered agent (including
|
|
@@ -893,31 +970,29 @@ const app = new Hono();
|
|
|
893
970
|
app.get('/health', (c) => c.json({ status: 'ok' }));
|
|
894
971
|
app.get('/agents', (c) => c.json(manifest));
|
|
895
972
|
|
|
896
|
-
//
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
}, 400);
|
|
901
|
-
});
|
|
902
|
-
|
|
903
|
-
app.post('/agents/:name/:id', async (c) => {
|
|
973
|
+
// Catch any method on the agent route so non-POSTs become 405 (instead of
|
|
974
|
+
// Hono's default 404 for unmatched method). Throws are translated by the
|
|
975
|
+
// onError handler into the canonical error envelope.
|
|
976
|
+
app.all('/agents/:name/:id', async (c) => {
|
|
904
977
|
const name = c.req.param('name');
|
|
905
978
|
const id = c.req.param('id');
|
|
906
979
|
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
980
|
+
// Validate method, name shape, registration, webhook-accessibility.
|
|
981
|
+
// Throws FlueHttpError on any failure; caught by app.onError below.
|
|
982
|
+
validateAgentRequest({
|
|
983
|
+
method: c.req.method,
|
|
984
|
+
name,
|
|
985
|
+
id,
|
|
986
|
+
registeredAgents: Object.keys(handlers),
|
|
987
|
+
webhookAgents: Array.from(webhookAgentSet),
|
|
988
|
+
allowNonWebhook: isLocalMode,
|
|
989
|
+
});
|
|
913
990
|
|
|
914
991
|
const handler = handlers[name];
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
payload = {};
|
|
920
|
-
}
|
|
992
|
+
|
|
993
|
+
// Parse the request body. Throws on invalid Content-Type or malformed JSON;
|
|
994
|
+
// returns {} for genuinely empty bodies (so no-payload agents still work).
|
|
995
|
+
const payload = await parseJsonBody(c.req.raw);
|
|
921
996
|
|
|
922
997
|
const accept = c.req.header('accept') || '';
|
|
923
998
|
const isWebhook = c.req.header('x-webhook') === 'true';
|
|
@@ -940,7 +1015,13 @@ app.post('/agents/:name/:id', async (c) => {
|
|
|
940
1015
|
return c.json({ status: 'accepted', requestId }, 202);
|
|
941
1016
|
}
|
|
942
1017
|
|
|
943
|
-
// SSE streaming mode
|
|
1018
|
+
// SSE streaming mode. Two error regimes meet here:
|
|
1019
|
+
// - Pre-stream errors (validation, body parsing, agent lookup) have
|
|
1020
|
+
// already thrown above and are rendered as plain HTTP responses by
|
|
1021
|
+
// app.onError — headers haven't been sent yet, so this works.
|
|
1022
|
+
// - Errors during agent execution surface as in-stream \`error\` events
|
|
1023
|
+
// with the canonical envelope (via toSseData), since by then the
|
|
1024
|
+
// 200 + text/event-stream headers are already on the wire.
|
|
944
1025
|
if (isSSE) {
|
|
945
1026
|
return streamSSE(c, async (stream) => {
|
|
946
1027
|
let eventId = 0;
|
|
@@ -964,7 +1045,7 @@ app.post('/agents/:name/:id', async (c) => {
|
|
|
964
1045
|
});
|
|
965
1046
|
} catch (err) {
|
|
966
1047
|
await stream.writeSSE({
|
|
967
|
-
data:
|
|
1048
|
+
data: toSseData(err),
|
|
968
1049
|
event: 'error',
|
|
969
1050
|
id: String(eventId++),
|
|
970
1051
|
});
|
|
@@ -978,18 +1059,29 @@ app.post('/agents/:name/:id', async (c) => {
|
|
|
978
1059
|
});
|
|
979
1060
|
}
|
|
980
1061
|
|
|
981
|
-
// Sync mode (default)
|
|
1062
|
+
// Sync mode (default). Errors propagate to app.onError.
|
|
1063
|
+
const ctx = createContextForRequest(id, payload);
|
|
982
1064
|
try {
|
|
983
|
-
const ctx = createContextForRequest(id, payload);
|
|
984
1065
|
const result = await handler(ctx);
|
|
985
|
-
ctx.setEventCallback(undefined);
|
|
986
1066
|
return c.json({ result: result !== undefined ? result : null });
|
|
987
|
-
}
|
|
988
|
-
|
|
989
|
-
return c.json({ error: String(err) }, 500);
|
|
1067
|
+
} finally {
|
|
1068
|
+
ctx.setEventCallback(undefined);
|
|
990
1069
|
}
|
|
991
1070
|
});
|
|
992
1071
|
|
|
1072
|
+
// 404 handler — fires for any URL that didn't match a registered route.
|
|
1073
|
+
app.notFound((c) => {
|
|
1074
|
+
// Throw rather than return so the onError handler is the single source of
|
|
1075
|
+
// truth for error-envelope shaping.
|
|
1076
|
+
throw new RouteNotFoundError({ method: c.req.method, path: new URL(c.req.url).pathname });
|
|
1077
|
+
});
|
|
1078
|
+
|
|
1079
|
+
// Single-source-of-truth error renderer. Every thrown FlueError (and every
|
|
1080
|
+
// thrown unknown) is converted to the canonical JSON envelope here.
|
|
1081
|
+
// toHttpResponse takes care of logging unknowns — no extra console.error
|
|
1082
|
+
// needed at this layer.
|
|
1083
|
+
app.onError((err) => toHttpResponse(err));
|
|
1084
|
+
|
|
993
1085
|
// ─── Start ──────────────────────────────────────────────────────────────────
|
|
994
1086
|
|
|
995
1087
|
const port = parseInt(process.env.PORT || '3000', 10);
|
|
@@ -1585,6 +1677,29 @@ var CloudflareReloader = class {
|
|
|
1585
1677
|
port;
|
|
1586
1678
|
configPath;
|
|
1587
1679
|
envFiles;
|
|
1680
|
+
/**
|
|
1681
|
+
* Stable container build ID for the lifetime of this reloader instance.
|
|
1682
|
+
*
|
|
1683
|
+
* `unstable_startWorker` does NOT default this field — only wrangler's CLI
|
|
1684
|
+
* path does, via `generateContainerBuildId()`. When the user's wrangler
|
|
1685
|
+
* config declares `containers[]` (e.g. via `@cloudflare/sandbox`), the
|
|
1686
|
+
* first `onBundleComplete` calls `getImageNameFromDOClassName(...)` which
|
|
1687
|
+
* asserts that `options.containerBuildId` is set; without this, the
|
|
1688
|
+
* assertion throws, the `ProxyController` never gets `reloadComplete`,
|
|
1689
|
+
* and every request hangs (including `/health`). See issue #22.
|
|
1690
|
+
*
|
|
1691
|
+
* We generate it once per reloader and reuse it across reloads so that
|
|
1692
|
+
* wrangler's container-prep cache hits when nothing about the image
|
|
1693
|
+
* changed. Format matches wrangler's own helper: an 8-char UUID slice.
|
|
1694
|
+
*/
|
|
1695
|
+
containerBuildId;
|
|
1696
|
+
/**
|
|
1697
|
+
* Bound listener for `DevEnv` `'error'` events. Stored so we can detach
|
|
1698
|
+
* it on `disposeWorker()` — the underlying `EventEmitter` outlives the
|
|
1699
|
+
* worker handle, so if the listener stayed attached we'd leak (and
|
|
1700
|
+
* double-fire) across reloads.
|
|
1701
|
+
*/
|
|
1702
|
+
errorListener = null;
|
|
1588
1703
|
url;
|
|
1589
1704
|
constructor(wrangler, opts) {
|
|
1590
1705
|
this.wrangler = wrangler;
|
|
@@ -1592,6 +1707,7 @@ var CloudflareReloader = class {
|
|
|
1592
1707
|
this.port = opts.port;
|
|
1593
1708
|
this.envFiles = opts.envFiles;
|
|
1594
1709
|
this.configPath = path.join(this.outputDir, "dist", "wrangler.jsonc");
|
|
1710
|
+
this.containerBuildId = randomUUID().slice(0, 8);
|
|
1595
1711
|
}
|
|
1596
1712
|
async start() {
|
|
1597
1713
|
await this.startWorker();
|
|
@@ -1650,9 +1766,18 @@ var CloudflareReloader = class {
|
|
|
1650
1766
|
port: this.port
|
|
1651
1767
|
},
|
|
1652
1768
|
watch: false,
|
|
1653
|
-
logLevel: "info"
|
|
1769
|
+
logLevel: "info",
|
|
1770
|
+
containerBuildId: this.containerBuildId
|
|
1654
1771
|
}
|
|
1655
1772
|
});
|
|
1773
|
+
this.errorListener = (event) => {
|
|
1774
|
+
const reason = event?.reason ?? "unknown error";
|
|
1775
|
+
const cause = event?.cause;
|
|
1776
|
+
const causeMsg = cause && typeof cause === "object" && "message" in cause ? cause.message : void 0;
|
|
1777
|
+
console.error(`[flue] Wrangler error (${event?.source ?? "unknown"}): ${reason}`);
|
|
1778
|
+
if (causeMsg) console.error(`[flue] ${causeMsg}`);
|
|
1779
|
+
};
|
|
1780
|
+
this.worker.raw.on("error", this.errorListener);
|
|
1656
1781
|
try {
|
|
1657
1782
|
this.url = (await this.worker.url).toString().replace(/\/$/, "");
|
|
1658
1783
|
} catch {
|
|
@@ -1661,8 +1786,13 @@ var CloudflareReloader = class {
|
|
|
1661
1786
|
}
|
|
1662
1787
|
async disposeWorker() {
|
|
1663
1788
|
const worker = this.worker;
|
|
1789
|
+
const listener = this.errorListener;
|
|
1664
1790
|
this.worker = null;
|
|
1791
|
+
this.errorListener = null;
|
|
1665
1792
|
if (!worker) return;
|
|
1793
|
+
if (listener) try {
|
|
1794
|
+
worker.raw.off("error", listener);
|
|
1795
|
+
} catch {}
|
|
1666
1796
|
try {
|
|
1667
1797
|
await worker.dispose();
|
|
1668
1798
|
} catch (err) {
|