@flow-engineer/flow 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +594 -2
- package/coding-agents/skill.md +321 -0
- package/coding-agents/test-skill.md +223 -0
- package/coding-agents/tools-skill.md +89 -0
- package/dist/flow-messaging/src/client.d.ts +34 -0
- package/dist/flow-messaging/src/client.js +130 -0
- package/dist/flow-messaging/src/index.d.ts +37 -0
- package/dist/flow-messaging/src/index.js +409 -0
- package/dist/simulators/src/index.d.ts +2 -0
- package/dist/simulators/src/index.js +2 -0
- package/dist/simulators/src/page.html +1245 -0
- package/dist/simulators/src/person.html +100 -0
- package/dist/simulators/src/platforms.d.ts +29 -0
- package/dist/simulators/src/platforms.js +72 -0
- package/dist/simulators/src/server.d.ts +201 -0
- package/dist/simulators/src/server.js +759 -0
- package/dist/src/agent.d.ts +504 -0
- package/dist/src/agent.js +818 -0
- package/dist/src/channels.d.ts +178 -0
- package/dist/src/channels.js +243 -0
- package/dist/src/conversation.d.ts +361 -0
- package/dist/src/conversation.js +1487 -0
- package/dist/src/db.d.ts +18 -0
- package/dist/src/db.js +57 -0
- package/dist/src/events.d.ts +17 -0
- package/dist/src/events.js +20 -0
- package/dist/src/http.d.ts +45 -0
- package/dist/src/http.js +269 -0
- package/dist/src/index.d.ts +14 -0
- package/dist/src/index.js +11 -0
- package/dist/src/model.d.ts +21 -0
- package/dist/src/model.js +96 -0
- package/dist/src/names.d.ts +17 -0
- package/dist/src/names.js +38 -0
- package/dist/src/nudges.d.ts +23 -0
- package/dist/src/nudges.js +117 -0
- package/dist/src/postgres.d.ts +20 -0
- package/dist/src/postgres.js +62 -0
- package/dist/src/schedules.d.ts +60 -0
- package/dist/src/schedules.js +249 -0
- package/dist/src/skills.d.ts +12 -0
- package/dist/src/skills.js +69 -0
- package/dist/src/store.d.ts +214 -0
- package/dist/src/store.js +448 -0
- package/dist/src/talk.d.ts +57 -0
- package/dist/src/talk.js +166 -0
- package/dist/src/time.d.ts +71 -0
- package/dist/src/time.js +170 -0
- package/dist/src/tools.d.ts +43 -0
- package/dist/src/tools.js +26 -0
- package/dist/src/traces.d.ts +31 -0
- package/dist/src/traces.js +303 -0
- package/dist/src/types.d.ts +260 -0
- package/dist/src/types.js +2 -0
- package/dist/testing/src/check-endpoint.d.ts +31 -0
- package/dist/testing/src/check-endpoint.js +158 -0
- package/dist/testing/src/chinwag.d.ts +2 -0
- package/dist/testing/src/chinwag.js +6 -0
- package/dist/testing/src/cli.d.ts +2 -0
- package/dist/testing/src/cli.js +563 -0
- package/dist/testing/src/client.d.ts +115 -0
- package/dist/testing/src/client.js +955 -0
- package/dist/testing/src/http-agent.d.ts +47 -0
- package/dist/testing/src/http-agent.js +155 -0
- package/dist/testing/src/index.d.ts +3 -0
- package/dist/testing/src/index.js +2 -0
- package/dist/testing/src/new.d.ts +9 -0
- package/dist/testing/src/new.js +122 -0
- package/dist/testing/src/print-run.d.ts +3 -0
- package/dist/testing/src/print-run.js +22 -0
- package/dist/testing/src/protocol.d.ts +159 -0
- package/dist/testing/src/protocol.js +28 -0
- package/dist/testing/src/report.d.ts +74 -0
- package/dist/testing/src/report.js +90 -0
- package/dist/testing/src/show.d.ts +36 -0
- package/dist/testing/src/show.js +280 -0
- package/dist/testing/src/suite.d.ts +87 -0
- package/dist/testing/src/suite.js +198 -0
- package/examples/cafe/agent/AGENTS.md +10 -0
- package/examples/cafe/agent/skills/allergens/SKILL.md +7 -0
- package/examples/cafe/src/agent.ts +39 -0
- package/examples/cafe/src/main.ts +16 -0
- package/examples/cafe/src/tools.ts +62 -0
- package/examples/cafe/tests/scenarios/latte-correction.yaml +30 -0
- package/examples/cafe/tests/scenarios/order-fails.yaml +26 -0
- package/examples/cafe/tests/tools/get_menu.yaml +14 -0
- package/examples/cafe/tests/tools/place_order.yaml +29 -0
- package/examples/http-agent/server.mjs +32 -0
- package/examples/http-agent/tests/scenarios/opening-hours.yaml +16 -0
- package/flow-messaging/README.md +152 -0
- package/package.json +65 -4
- package/spec/agent-endpoint.openapi.yaml +148 -0
- package/spec/nudges.openapi.yaml +167 -0
- package/spec/request.schema.json +121 -0
- package/spec/response.schema.json +284 -0
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { type ChannelInfo, type ChannelSummary, type Deliver, type EventSink, type Incoming, type Outbound, type ReceiveOptions } from "../../src/index.ts";
|
|
2
|
+
export interface HttpAgentOptions {
|
|
3
|
+
url: string;
|
|
4
|
+
/** Sent as `authorization: Bearer <token>` (FLOW_FRAMEWORK_AGENT_TOKEN). */
|
|
5
|
+
token?: string;
|
|
6
|
+
/** The channel every message names; default the platform the run plays (whatsapp, sms, imessage, telegram, web). */
|
|
7
|
+
channel?: string;
|
|
8
|
+
/** How long one message waits for its answer. Default 60 s. */
|
|
9
|
+
timeoutMs?: number;
|
|
10
|
+
events?: EventSink[];
|
|
11
|
+
log?: (line: string) => void;
|
|
12
|
+
}
|
|
13
|
+
/** The person's message as the agent's endpoint gets it. */
|
|
14
|
+
export interface HttpAgentRequest {
|
|
15
|
+
conversationId: string;
|
|
16
|
+
/** The id the cloud gave this message (m1, m2, ...): a reply's replyTo, a reaction's or a read's messageId. */
|
|
17
|
+
messageId: string;
|
|
18
|
+
channel: string;
|
|
19
|
+
message: {
|
|
20
|
+
text: string;
|
|
21
|
+
replyTo?: Incoming["replyTo"];
|
|
22
|
+
choice?: Incoming["choice"];
|
|
23
|
+
attachments?: Array<{
|
|
24
|
+
kind: "image" | "audio" | "document";
|
|
25
|
+
mime: string;
|
|
26
|
+
name?: string;
|
|
27
|
+
data: string;
|
|
28
|
+
}>;
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
export declare class TurnError extends Error {
|
|
32
|
+
}
|
|
33
|
+
/** POSTs one message and returns its replies, unchecked; throws a TurnError saying why when the answer fails the turn. */
|
|
34
|
+
export declare function postMessage(options: Pick<HttpAgentOptions, "url" | "token">, timeoutMs: number, body: HttpAgentRequest): Promise<unknown[]>;
|
|
35
|
+
/** The reply as the platform takes it, or why it cannot go: the checks a Flow Framework agent's send gets. */
|
|
36
|
+
export declare function checkReply(reply: unknown, info: ChannelInfo): {
|
|
37
|
+
outbound: Outbound;
|
|
38
|
+
} | {
|
|
39
|
+
refused: string;
|
|
40
|
+
};
|
|
41
|
+
export declare function httpAgent(options: HttpAgentOptions): {
|
|
42
|
+
/** None of its own: each run plays its platform as the platform is. */
|
|
43
|
+
channels(): ChannelSummary[];
|
|
44
|
+
receive(conversationId: string, message: string | Incoming, deliver: Deliver, receive?: ReceiveOptions): string | null;
|
|
45
|
+
/** The person reacted to one of its messages: not sent to the endpoint in this version. */
|
|
46
|
+
reacted(_conversationId: string, _emoji: string, _toText: string): void;
|
|
47
|
+
};
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// An agent you already have, on any framework, tested through its HTTP endpoint: `flow test
|
|
2
|
+
// --agent-url <url>`. This plays the part of a Flow Framework Agent on the developer's machine: each of the
|
|
3
|
+
// person's messages is one POST to the URL from here (so the agent never has to be reachable from
|
|
4
|
+
// the internet), and the replies it answers go to the test cloud as a Flow Framework agent's would. Its own
|
|
5
|
+
// tools run on its side, for real.
|
|
6
|
+
//
|
|
7
|
+
// POST <url> { conversationId, messageId, channel, message: { text, attachments?: [{ kind, mime, name?, data }] } }
|
|
8
|
+
// 200 { replies: Outbound[] } (data is base64; [] is no reply)
|
|
9
|
+
//
|
|
10
|
+
// A conversation's messages go one at a time, in order: the next is sent once the last one's answer
|
|
11
|
+
// is in. Each reply is checked against what the run's platform can show, as a Flow Framework agent's send
|
|
12
|
+
// is. Agents that answer later (through a callback) are not supported yet.
|
|
13
|
+
import { Buffer } from "node:buffer";
|
|
14
|
+
import { emitter } from "../../src/events.js";
|
|
15
|
+
import { fitMessage, z } from "../../src/index.js";
|
|
16
|
+
import { sendInput, sendRefusal } from "../../src/talk.js";
|
|
17
|
+
const answerSchema = z.object({ replies: z.array(z.unknown()) });
|
|
18
|
+
const otherReply = z.discriminatedUnion("type", [
|
|
19
|
+
z.object({ type: z.literal("read"), messageIds: z.array(z.string()) }),
|
|
20
|
+
z.object({ type: z.literal("reaction"), emoji: z.string().min(1), messageId: z.string().min(1) }),
|
|
21
|
+
z.looseObject({ type: z.literal("template"), name: z.string().min(1) }),
|
|
22
|
+
z.object({ type: z.literal("status"), text: z.string().nullable() }),
|
|
23
|
+
z.object({ type: z.literal("typing"), on: z.boolean() }),
|
|
24
|
+
]);
|
|
25
|
+
export class TurnError extends Error {
|
|
26
|
+
}
|
|
27
|
+
const problems = (error) => error.issues.map((i) => (i.code === "unrecognized_keys" ? `${i.keys.join(", ")}: not something this platform shows` : (i.path.length ? `${i.path.join(".")}: ` : "") + i.message)).join("; ");
|
|
28
|
+
/** POSTs one message and returns its replies, unchecked; throws a TurnError saying why when the answer fails the turn. */
|
|
29
|
+
export async function postMessage(options, timeoutMs, body) {
|
|
30
|
+
let response;
|
|
31
|
+
let text;
|
|
32
|
+
try {
|
|
33
|
+
response = await fetch(options.url, {
|
|
34
|
+
method: "POST",
|
|
35
|
+
headers: { "content-type": "application/json", ...(options.token ? { authorization: `Bearer ${options.token}` } : {}) },
|
|
36
|
+
body: JSON.stringify(body),
|
|
37
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
38
|
+
});
|
|
39
|
+
text = await response.text();
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
const e = error;
|
|
43
|
+
if (e.name === "TimeoutError")
|
|
44
|
+
throw new TurnError(`no answer from ${options.url} within ${timeoutMs / 1000} s`);
|
|
45
|
+
throw new TurnError(`could not reach ${options.url}: ${e.cause?.code ?? e.cause?.message ?? e.message}`);
|
|
46
|
+
}
|
|
47
|
+
if (response.status !== 200)
|
|
48
|
+
throw new TurnError(`${options.url} answered ${response.status}${text.trim() ? `: ${text.trim().slice(0, 200)}` : ""}`);
|
|
49
|
+
let parsed;
|
|
50
|
+
try {
|
|
51
|
+
parsed = JSON.parse(text);
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
throw new TurnError(`${options.url} answered 200 with a body that is not JSON: ${text.slice(0, 200)}`);
|
|
55
|
+
}
|
|
56
|
+
const answer = answerSchema.safeParse(parsed);
|
|
57
|
+
if (!answer.success)
|
|
58
|
+
throw new TurnError(`${options.url} answered 200 without { "replies": [...] }: ${text.slice(0, 200)}`);
|
|
59
|
+
return answer.data.replies;
|
|
60
|
+
}
|
|
61
|
+
/** The reply as the platform takes it, or why it cannot go: the checks a Flow Framework agent's send gets. */
|
|
62
|
+
export function checkReply(reply, info) {
|
|
63
|
+
const type = reply?.type;
|
|
64
|
+
if (type !== "message") {
|
|
65
|
+
const other = otherReply.safeParse(reply);
|
|
66
|
+
return other.success ? { outbound: other.data } : { refused: problems(other.error) };
|
|
67
|
+
}
|
|
68
|
+
const { type: _, replyTo, fields, ...rest } = reply;
|
|
69
|
+
const input = { ...rest, ...(replyTo !== undefined ? { reply_to: replyTo } : {}) };
|
|
70
|
+
const sent = sendInput(info).strict().safeParse(input);
|
|
71
|
+
if (!sent.success)
|
|
72
|
+
return { refused: problems(sent.error).replaceAll("reply_to", "replyTo") };
|
|
73
|
+
if (fields !== undefined && (typeof fields !== "object" || fields === null || Array.isArray(fields)))
|
|
74
|
+
return { refused: "fields: must be an object" };
|
|
75
|
+
const refusal = sendRefusal(info, input);
|
|
76
|
+
if (refusal)
|
|
77
|
+
return { refused: refusal };
|
|
78
|
+
return { outbound: fitMessage(info, { type: "message", ...rest, ...(replyTo !== undefined ? { replyTo } : {}), ...(fields ? { fields } : {}) }) };
|
|
79
|
+
}
|
|
80
|
+
export function httpAgent(options) {
|
|
81
|
+
const emit = emitter(options.events ?? []);
|
|
82
|
+
const log = options.log ?? ((line) => console.log(line));
|
|
83
|
+
const timeoutMs = options.timeoutMs ?? 60_000;
|
|
84
|
+
/** Each conversation's last message, so the next one waits for its answer. */
|
|
85
|
+
const queues = new Map();
|
|
86
|
+
async function turn(conversationId, request, incoming, info, deliver) {
|
|
87
|
+
const started = Date.now();
|
|
88
|
+
emit(conversationId, "turn.start", {});
|
|
89
|
+
try {
|
|
90
|
+
if (incoming.attachments?.length) {
|
|
91
|
+
request.message.attachments = await Promise.all(incoming.attachments.map(async (a) => ({ kind: a.kind, mime: a.mime, ...(a.name ? { name: a.name } : {}), data: Buffer.from(await a.data()).toString("base64") })));
|
|
92
|
+
}
|
|
93
|
+
for (const reply of await postMessage(options, timeoutMs, request)) {
|
|
94
|
+
const checked = checkReply(reply, info);
|
|
95
|
+
const text = reply?.text;
|
|
96
|
+
const shown = typeof text === "string" ? text : `(${String(reply?.type ?? "reply")})`;
|
|
97
|
+
if ("refused" in checked) {
|
|
98
|
+
log(`${conversationId}: a reply was not sent: ${checked.refused}`);
|
|
99
|
+
emit(conversationId, "message.undelivered", { text: shown, reason: checked.refused });
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
const outbound = checked.outbound;
|
|
103
|
+
try {
|
|
104
|
+
await deliver(outbound);
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
emit(conversationId, "message.undelivered", { text: shown, reason: error.message });
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if (outbound.type === "message") {
|
|
111
|
+
const { type: _, ...sent } = outbound;
|
|
112
|
+
emit(conversationId, "message.out", { ...sent, channel: request.channel });
|
|
113
|
+
}
|
|
114
|
+
else if (outbound.type === "reaction") {
|
|
115
|
+
emit(conversationId, "reaction", { emoji: outbound.emoji, messageId: outbound.messageId });
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
catch (error) {
|
|
120
|
+
const reason = error instanceof TurnError ? error.message : `the message could not be sent: ${error.message}`;
|
|
121
|
+
log(`${conversationId}: ${reason}`);
|
|
122
|
+
emit(conversationId, "model.error", { error: reason });
|
|
123
|
+
emit(conversationId, "message.undelivered", { text: "(the agent's answer)", reason });
|
|
124
|
+
}
|
|
125
|
+
finally {
|
|
126
|
+
emit(conversationId, "turn.end", { ms: Date.now() - started });
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return {
|
|
130
|
+
/** None of its own: each run plays its platform as the platform is. */
|
|
131
|
+
channels() {
|
|
132
|
+
return [];
|
|
133
|
+
},
|
|
134
|
+
receive(conversationId, message, deliver, receive = {}) {
|
|
135
|
+
const incoming = typeof message === "string" ? { text: message } : message;
|
|
136
|
+
const messageId = receive.messageId ?? "";
|
|
137
|
+
// The cloud's conversations are "<platform>:<person>".
|
|
138
|
+
const channel = options.channel ?? /^([a-z]+):/.exec(conversationId)?.[1] ?? "chat";
|
|
139
|
+
const request = {
|
|
140
|
+
conversationId,
|
|
141
|
+
messageId,
|
|
142
|
+
channel,
|
|
143
|
+
message: { text: incoming.text, ...(incoming.replyTo ? { replyTo: incoming.replyTo } : {}), ...(incoming.choice ? { choice: incoming.choice } : {}) },
|
|
144
|
+
};
|
|
145
|
+
const attachments = incoming.attachments?.map((a) => ({ kind: a.kind, mime: a.mime, ...(a.name ? { name: a.name } : {}) }));
|
|
146
|
+
emit(conversationId, "message.in", { text: incoming.text, messageId, channel, ...(attachments ? { attachments } : {}) });
|
|
147
|
+
const info = receive.channel ?? { reactions: false, status: false };
|
|
148
|
+
const next = (queues.get(conversationId) ?? Promise.resolve()).then(() => turn(conversationId, request, incoming, info, deliver));
|
|
149
|
+
queues.set(conversationId, next);
|
|
150
|
+
return messageId || null;
|
|
151
|
+
},
|
|
152
|
+
/** The person reacted to one of its messages: not sent to the endpoint in this version. */
|
|
153
|
+
reacted(_conversationId, _emoji, _toText) { },
|
|
154
|
+
};
|
|
155
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export { runInCloud, type CloudSummary, type ConnectOptions } from "./client.ts";
|
|
2
|
+
export { hashOf, loadSuite, parseScenario, parseToolFile, suiteHash, type Scenario, type SuiteFile, type ToolFile } from "./suite.ts";
|
|
3
|
+
export type { Check, Command, OpenSession, Opened, RunKey, RunSummary, SessionSummary, ToolCallRequest, ToolCallResult, UploadFile, Usage, WireIncoming } from "./protocol.ts";
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** Throws unless `name` can be an agent's folder and package name. */
|
|
2
|
+
export declare function checkName(name: string): void;
|
|
3
|
+
/** The @flow-engineer/flow package's folder: the root of a clone, or the installed package. */
|
|
4
|
+
export declare function packageRoot(from?: string): string;
|
|
5
|
+
/** Makes `<into>/<name>` (the current folder by default) and returns it. */
|
|
6
|
+
export declare function newStandalone(name: string, options?: {
|
|
7
|
+
into?: string;
|
|
8
|
+
root?: string;
|
|
9
|
+
}): string;
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// `flow new <name>`: makes ./<name>/, an agent of its own started from the café example (Pip),
|
|
2
|
+
// that depends on the @flow-engineer/flow package. Its imports become "@flow-engineer/flow" and "@flow-engineer/flow/simulators", and
|
|
3
|
+
// the coding-agent skills go in .claude/skills/, with an AGENTS.md (and a CLAUDE.md importing it)
|
|
4
|
+
// pointing at them. Inside a clone, `npm run new -- <name>` makes agents/<name>/ instead.
|
|
5
|
+
import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { dirname, join } from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
const NAME = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
9
|
+
/** Throws unless `name` can be an agent's folder and package name. */
|
|
10
|
+
export function checkName(name) {
|
|
11
|
+
if (!NAME.test(name))
|
|
12
|
+
throw new Error(`"${name}" is not a usable name: use lowercase letters, digits and dashes, such as my-agent.`);
|
|
13
|
+
}
|
|
14
|
+
/** The @flow-engineer/flow package's folder: the root of a clone, or the installed package. */
|
|
15
|
+
export function packageRoot(from = dirname(fileURLToPath(import.meta.url))) {
|
|
16
|
+
for (let dir = from;; dir = dirname(dir)) {
|
|
17
|
+
const file = join(dir, "package.json");
|
|
18
|
+
if (existsSync(file) && JSON.parse(readFileSync(file, "utf8")).name === "@flow-engineer/flow")
|
|
19
|
+
return dir;
|
|
20
|
+
if (dirname(dir) === dir)
|
|
21
|
+
throw new Error("flow: can't find its own package.json");
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
const TSCONFIG = {
|
|
25
|
+
compilerOptions: {
|
|
26
|
+
target: "es2023",
|
|
27
|
+
module: "nodenext",
|
|
28
|
+
moduleResolution: "nodenext",
|
|
29
|
+
strict: true,
|
|
30
|
+
noEmit: true,
|
|
31
|
+
allowImportingTsExtensions: true,
|
|
32
|
+
erasableSyntaxOnly: true,
|
|
33
|
+
verbatimModuleSyntax: true,
|
|
34
|
+
skipLibCheck: true,
|
|
35
|
+
types: ["node"],
|
|
36
|
+
},
|
|
37
|
+
include: ["src"],
|
|
38
|
+
};
|
|
39
|
+
const ENV_EXAMPLE = `# One model key. The first line with a value is the one used.
|
|
40
|
+
# Make it at the provider, then paste it here. Never commit this file.
|
|
41
|
+
# OPENROUTER_API_KEY https://openrouter.ai/keys
|
|
42
|
+
# OPENAI_API_KEY https://platform.openai.com/api-keys
|
|
43
|
+
# ANTHROPIC_API_KEY https://console.anthropic.com/settings/keys
|
|
44
|
+
# GEMINI_API_KEY https://aistudio.google.com/apikey
|
|
45
|
+
OPENROUTER_API_KEY=
|
|
46
|
+
OPENAI_API_KEY=
|
|
47
|
+
ANTHROPIC_API_KEY=
|
|
48
|
+
GEMINI_API_KEY=
|
|
49
|
+
|
|
50
|
+
# Optional. The model id for that provider; empty is that provider's default.
|
|
51
|
+
# CAFE_MODEL=
|
|
52
|
+
|
|
53
|
+
# The test cloud (npx flow test --synthetic) and traces. Sign in at https://flow.engineer/observe,
|
|
54
|
+
# add the agent, and on its Developers page make a CI key (oci_..., shown once); the trace key is
|
|
55
|
+
# from the same site.
|
|
56
|
+
FLOW_FRAMEWORK_CLOUD=https://flow.engineer/observe
|
|
57
|
+
FLOW_FRAMEWORK_CLOUD_KEY=
|
|
58
|
+
FLOW_FRAMEWORK_TRACES_URL=https://flow.engineer/observe
|
|
59
|
+
FLOW_FRAMEWORK_TRACES_KEY=
|
|
60
|
+
`;
|
|
61
|
+
const agentsMd = (name) => `# ${name}
|
|
62
|
+
|
|
63
|
+
A chat agent on Flow Framework (https://github.com/flow-engineer/flow), started from its café example (Pip).
|
|
64
|
+
|
|
65
|
+
- Load the skills in \`.claude/skills/\` before changing it: \`flow-framework\` to build the agent, \`flow-framework-tools\`
|
|
66
|
+
for its tools and their test files, \`flow-framework-tests\` for its test scenarios.
|
|
67
|
+
- \`agent/AGENTS.md\` is the agent's own prompt, not instructions for coding agents.
|
|
68
|
+
- \`npm start\` runs it in the playground. The owner puts one model key in \`.env\` (never read or
|
|
69
|
+
print it): \`OPENROUTER_API_KEY\`, \`OPENAI_API_KEY\`, \`ANTHROPIC_API_KEY\` or \`GEMINI_API_KEY\`.
|
|
70
|
+
\`npm run typecheck\` checks the types, \`npm test -- --synthetic\` runs the tests in the test
|
|
71
|
+
cloud at https://flow.engineer/observe with the owner's \`FLOW_FRAMEWORK_CLOUD_KEY\` (\`npx flow --help\`).
|
|
72
|
+
`;
|
|
73
|
+
/** Makes `<into>/<name>` (the current folder by default) and returns it. */
|
|
74
|
+
export function newStandalone(name, options = {}) {
|
|
75
|
+
checkName(name);
|
|
76
|
+
const root = options.root ?? packageRoot();
|
|
77
|
+
const dir = join(options.into ?? process.cwd(), name);
|
|
78
|
+
if (existsSync(dir))
|
|
79
|
+
throw new Error(`${dir} already exists: pick another name or remove it.`);
|
|
80
|
+
const cafe = join(root, "examples", "cafe");
|
|
81
|
+
for (const part of ["agent", "src", "tests"])
|
|
82
|
+
cpSync(join(cafe, part), join(dir, part), { recursive: true });
|
|
83
|
+
for (const file of readdirSync(join(dir, "src"), { recursive: true, encoding: "utf8" }).filter((f) => f.endsWith(".ts"))) {
|
|
84
|
+
const path = join(dir, "src", file);
|
|
85
|
+
const source = readFileSync(path, "utf8")
|
|
86
|
+
.replace(/(["'])(\.\.\/)+src\/index\.ts\1/g, '"@flow-engineer/flow"')
|
|
87
|
+
.replace(/(["'])(\.\.\/)+simulators\/src\/index\.ts\1/g, '"@flow-engineer/flow/simulators"');
|
|
88
|
+
if (/["']\.\.\/\.\.\//.test(source))
|
|
89
|
+
throw new Error(`flow: ${file} imports a file outside the agent; only Flow Framework's public entries can be rewritten.`);
|
|
90
|
+
writeFileSync(path, source);
|
|
91
|
+
}
|
|
92
|
+
const own = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
|
|
93
|
+
const pkg = {
|
|
94
|
+
name,
|
|
95
|
+
version: "0.1.0",
|
|
96
|
+
private: true,
|
|
97
|
+
description: `${name}: a Flow Framework agent, started from the café example.`,
|
|
98
|
+
type: "module",
|
|
99
|
+
engines: own.engines,
|
|
100
|
+
scripts: { start: "node --env-file-if-exists=.env src/main.ts", test: "flow test", typecheck: "tsc --noEmit" },
|
|
101
|
+
dependencies: { "@flow-engineer/flow": `^${own.version}` },
|
|
102
|
+
devDependencies: { "@types/node": own.devDependencies["@types/node"], typescript: own.devDependencies.typescript },
|
|
103
|
+
};
|
|
104
|
+
writeFileSync(join(dir, "package.json"), `${JSON.stringify(pkg, null, 2)}\n`);
|
|
105
|
+
writeFileSync(join(dir, "tsconfig.json"), `${JSON.stringify(TSCONFIG, null, 2)}\n`);
|
|
106
|
+
writeFileSync(join(dir, ".gitignore"), "node_modules/\n.env\n.flow/\n");
|
|
107
|
+
// npm install then refuses a Node older than the engines field says, before .ts files fail to load.
|
|
108
|
+
writeFileSync(join(dir, ".npmrc"), "engine-strict=true\n");
|
|
109
|
+
writeFileSync(join(dir, ".env.example"), ENV_EXAMPLE);
|
|
110
|
+
const skills = join(root, "coding-agents");
|
|
111
|
+
for (const file of readdirSync(skills).filter((f) => f.endsWith(".md"))) {
|
|
112
|
+
const text = readFileSync(join(skills, file), "utf8");
|
|
113
|
+
const skill = /^name:\s*(\S+)\s*$/m.exec(text)?.[1];
|
|
114
|
+
if (!skill)
|
|
115
|
+
throw new Error(`flow: coding-agents/${file} has no name in its front matter.`);
|
|
116
|
+
mkdirSync(join(dir, ".claude", "skills", skill), { recursive: true });
|
|
117
|
+
writeFileSync(join(dir, ".claude", "skills", skill, "SKILL.md"), text);
|
|
118
|
+
}
|
|
119
|
+
writeFileSync(join(dir, "AGENTS.md"), agentsMd(name));
|
|
120
|
+
writeFileSync(join(dir, "CLAUDE.md"), "@AGENTS.md\n");
|
|
121
|
+
return dir;
|
|
122
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { runVerdict } from "./report.js";
|
|
2
|
+
const usd = (n) => `$${n.toFixed(4)}`;
|
|
3
|
+
/** Prints each run once. A run already shown as it finished is not printed again with the summary. */
|
|
4
|
+
export function runPrinter(log = console.log) {
|
|
5
|
+
const printed = new Set();
|
|
6
|
+
return (r) => {
|
|
7
|
+
if (printed.has(r.runId))
|
|
8
|
+
return;
|
|
9
|
+
printed.add(r.runId);
|
|
10
|
+
const verdict = runVerdict(r);
|
|
11
|
+
const gaveUp = verdict === "passed" && r.ended === "gave_up" ? " (passed, the person gave up)" : "";
|
|
12
|
+
const retried = r.retried ? "; tried once more" : "";
|
|
13
|
+
// An errored run: the cloud could not play or judge it, so its checks say nothing about the agent.
|
|
14
|
+
const error = verdict === "errored" ? `; error: ${r.error ?? r.ended} (the cloud could not play or judge this run, not the agent's fault)` : "";
|
|
15
|
+
const label = verdict === "errored" ? "ERROR" : verdict === "passed" ? "PASS" : "FAIL";
|
|
16
|
+
log(`${label} ${r.scenario} #${r.run} (${r.channel}) ${r.runId}: ${r.turns} turn${r.turns === 1 ? "" : "s"}, ended: ${r.ended}${gaveUp}${retried}${error}; slowest acknowledgement ${r.slowestAckMs === null ? "never" : `${r.slowestAckMs} ms`}; cost ${usd(r.agentUsage.costUsd)} agent + ${usd(r.testUsage.costUsd)} test`);
|
|
17
|
+
if (verdict !== "errored")
|
|
18
|
+
for (const c of r.checks.filter((c) => !c.pass))
|
|
19
|
+
log(` ✗ ${c.kind}: ${c.text}\n ${c.reason}`);
|
|
20
|
+
log(` watch: ${r.watch}`);
|
|
21
|
+
};
|
|
22
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import type { Attachment, ChannelInfo, ChannelSummary, Incoming, ToolSpec } from "../../src/index.ts";
|
|
2
|
+
/** One graded expect item of a run. */
|
|
3
|
+
export interface Check {
|
|
4
|
+
kind: "outcome" | "judge";
|
|
5
|
+
text: string;
|
|
6
|
+
pass: boolean;
|
|
7
|
+
reason: string;
|
|
8
|
+
}
|
|
9
|
+
/** Model tokens and their cost, added up over a run. */
|
|
10
|
+
export interface Usage {
|
|
11
|
+
input: number;
|
|
12
|
+
output: number;
|
|
13
|
+
costUsd: number;
|
|
14
|
+
}
|
|
15
|
+
export interface OpenSession {
|
|
16
|
+
/** name: a stable key (the package name) that keeps the agent's history together; title: for people. */
|
|
17
|
+
agent: {
|
|
18
|
+
name: string;
|
|
19
|
+
title?: string;
|
|
20
|
+
tools: ToolSpec[];
|
|
21
|
+
channels?: ChannelSummary[];
|
|
22
|
+
};
|
|
23
|
+
/** Every file in the local suite; only these tests run. */
|
|
24
|
+
files: Array<{
|
|
25
|
+
path: string;
|
|
26
|
+
hash: string;
|
|
27
|
+
}>;
|
|
28
|
+
/** How many times each scenario runs. */
|
|
29
|
+
runs: number;
|
|
30
|
+
/** Acknowledge each message independently of the serial event uploads. */
|
|
31
|
+
deliveryReceipts?: boolean;
|
|
32
|
+
/** The client will retry a call when this cloud stores the result under Idempotency-Key. */
|
|
33
|
+
retries?: boolean;
|
|
34
|
+
/** The client opens a lost stream again with Last-Event-ID when this cloud resumes. */
|
|
35
|
+
resume?: boolean;
|
|
36
|
+
/** The client opens one new session for the unfinished runs when this cloud says session_gone. */
|
|
37
|
+
reopen?: boolean;
|
|
38
|
+
/** Play only these runs, numbered as given: a re-open after session_gone. Sent only to a cloud whose Opened said reopen,
|
|
39
|
+
* and only when some run finished: the cloud checks it against the suite's files it already stores. */
|
|
40
|
+
only?: RunKey[];
|
|
41
|
+
}
|
|
42
|
+
/** One run of a suite: a scenario's id, one of its channels, and which of the `runs` times (from 1). */
|
|
43
|
+
export interface RunKey {
|
|
44
|
+
scenario: string;
|
|
45
|
+
channel: string;
|
|
46
|
+
run: number;
|
|
47
|
+
}
|
|
48
|
+
export interface Receipt {
|
|
49
|
+
conversationId: string;
|
|
50
|
+
messageId: string;
|
|
51
|
+
status: "taken" | "intercepted";
|
|
52
|
+
}
|
|
53
|
+
export interface Opened {
|
|
54
|
+
sessionId: string;
|
|
55
|
+
token: string;
|
|
56
|
+
/** Hashes the cloud does not have yet; upload these. */
|
|
57
|
+
missing: string[];
|
|
58
|
+
/** Where to watch this session's runs. */
|
|
59
|
+
dashboard: string;
|
|
60
|
+
/** The tunnel's absolute URL, where every later call goes (a hosted cloud's front door serves
|
|
61
|
+
* only the open). Absent or empty: the open's own address (a local cloud). */
|
|
62
|
+
tunnel?: string;
|
|
63
|
+
/** This cloud stores each Idempotency-Key with its result. Absent on an older cloud. */
|
|
64
|
+
idempotency?: boolean;
|
|
65
|
+
/** This cloud numbers stream commands with an SSE id, sends the ones after Last-Event-ID to a
|
|
66
|
+
* stream opened again, and holds them 2 minutes while none is attached. Absent on an older cloud. */
|
|
67
|
+
resume?: boolean;
|
|
68
|
+
/** This cloud answers session_gone for a session it lost, and plays `only` on an open. Absent on an older cloud. */
|
|
69
|
+
reopen?: boolean;
|
|
70
|
+
}
|
|
71
|
+
export interface UploadFile {
|
|
72
|
+
hash: string;
|
|
73
|
+
path: string;
|
|
74
|
+
content: string;
|
|
75
|
+
}
|
|
76
|
+
export type Command = {
|
|
77
|
+
type: "message";
|
|
78
|
+
conversationId: string;
|
|
79
|
+
messageId: string;
|
|
80
|
+
incoming: WireIncoming;
|
|
81
|
+
channel: ChannelInfo;
|
|
82
|
+
channelName?: string;
|
|
83
|
+
} | {
|
|
84
|
+
type: "reaction";
|
|
85
|
+
conversationId: string;
|
|
86
|
+
emoji: string;
|
|
87
|
+
toText: string;
|
|
88
|
+
} | {
|
|
89
|
+
type: "drain";
|
|
90
|
+
} | {
|
|
91
|
+
type: "run";
|
|
92
|
+
run: RunSummary;
|
|
93
|
+
} | {
|
|
94
|
+
type: "finished";
|
|
95
|
+
summary: SessionSummary;
|
|
96
|
+
}
|
|
97
|
+
/** The session stopped before it finished; the CLI exits with this error. */
|
|
98
|
+
| {
|
|
99
|
+
type: "failed";
|
|
100
|
+
error: string;
|
|
101
|
+
};
|
|
102
|
+
/**
|
|
103
|
+
* A person's message as it travels: a scenario's file goes by its path in the tests folder, and the
|
|
104
|
+
* agent's side reads it there, so the file never leaves the developer's machine.
|
|
105
|
+
*/
|
|
106
|
+
export type WireIncoming = Omit<Incoming, "attachments"> & {
|
|
107
|
+
attachments?: Array<{
|
|
108
|
+
kind: Attachment["kind"];
|
|
109
|
+
file: string;
|
|
110
|
+
}>;
|
|
111
|
+
};
|
|
112
|
+
export interface ToolCallRequest {
|
|
113
|
+
conversationId: string;
|
|
114
|
+
tool: string;
|
|
115
|
+
input: unknown;
|
|
116
|
+
}
|
|
117
|
+
export type ToolCallResult = {
|
|
118
|
+
ok: true;
|
|
119
|
+
output: unknown;
|
|
120
|
+
} | {
|
|
121
|
+
ok: false;
|
|
122
|
+
error: string;
|
|
123
|
+
};
|
|
124
|
+
export interface RunSummary {
|
|
125
|
+
runId: string;
|
|
126
|
+
scenario: string;
|
|
127
|
+
run: number;
|
|
128
|
+
channel: string;
|
|
129
|
+
/** Every check passed. */
|
|
130
|
+
passed: boolean;
|
|
131
|
+
checks: Check[];
|
|
132
|
+
/** Why the person stopped: goal met, gave up, max turns, or the agent went quiet. */
|
|
133
|
+
ended: string;
|
|
134
|
+
turns: number;
|
|
135
|
+
messagesIn: number;
|
|
136
|
+
messagesOut: number;
|
|
137
|
+
toolCalls: number;
|
|
138
|
+
/** Slowest acknowledgement of a person's message, in ms (null if one was never acknowledged). */
|
|
139
|
+
slowestAckMs: number | null;
|
|
140
|
+
/** The agent's model calls in this run, from its own events, and the models that answered them. */
|
|
141
|
+
agentUsage: Usage;
|
|
142
|
+
agentModels: string[];
|
|
143
|
+
/** The test's own model calls in this run: the person, the tool simulator and the judge. */
|
|
144
|
+
testUsage: Usage;
|
|
145
|
+
watch: string;
|
|
146
|
+
/** The first try never reached the agent, so this run is the second try. */
|
|
147
|
+
retried?: boolean;
|
|
148
|
+
/** The cloud's code when it could not play or judge the run (judge_failed, spend_cap, simulator_failed, ...).
|
|
149
|
+
* The run then says nothing about the agent, whatever `passed` and `ended` say. */
|
|
150
|
+
error?: string;
|
|
151
|
+
}
|
|
152
|
+
export interface SessionSummary {
|
|
153
|
+
sessionId: string;
|
|
154
|
+
suiteHash: string;
|
|
155
|
+
/** The model that played the person and the tools and judged. */
|
|
156
|
+
testModel: string;
|
|
157
|
+
runs: RunSummary[];
|
|
158
|
+
dashboard: string;
|
|
159
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// What travels through the tunnel between an agent on a developer's machine and the test cloud.
|
|
2
|
+
// Every connection is opened by the agent's side (plain HTTP), so it works behind any router.
|
|
3
|
+
//
|
|
4
|
+
// POST /api/sessions OpenSession → Opened start a session, learn which files to upload
|
|
5
|
+
// POST /api/files { files: UploadFile[] } upload the files the cloud lacks
|
|
6
|
+
// GET /api/sessions/:id/stream server-sent Commands the person's side, cloud → agent
|
|
7
|
+
// Last-Event-ID: N a resume: the commands after N
|
|
8
|
+
// POST /api/sessions/:id/outbound { conversationId, outbound } → { ok } | 409 { error }
|
|
9
|
+
// POST /api/sessions/:id/tool-calls ToolCallRequest → ToolCallResult held open while the tool is simulated
|
|
10
|
+
// POST /api/sessions/:id/events { seq, events: FlowEvent[] }
|
|
11
|
+
//
|
|
12
|
+
// Every POST after the open sends Idempotency-Key. An older cloud ignores it. When Opened
|
|
13
|
+
// says idempotency, a repeat of a key is the stored answer and the client may send it again.
|
|
14
|
+
// POST /api/sessions/:id/receipts Receipt → { ok }
|
|
15
|
+
// POST /api/sessions/:id/drained {} → { ok } flush before the terminal command
|
|
16
|
+
//
|
|
17
|
+
// When Opened says resume, every command on the stream carries `id: N` (1, 2, 3, ... per session).
|
|
18
|
+
// A stream that drops, or sits silent past the idle watch, is opened again with Last-Event-ID set
|
|
19
|
+
// to the last id the client handled (0 for none); the cloud sends the commands after it, and holds
|
|
20
|
+
// them for 2 minutes while no stream is attached. The client skips an id it has handled.
|
|
21
|
+
//
|
|
22
|
+
// When Opened says reopen, a tunnel call or the stream may answer 410 { error: "session_gone" }: the
|
|
23
|
+
// cloud lost the session (its tunnel restarted). The client opens one new session with `only`, the
|
|
24
|
+
// runs that have no result yet, and merges the two reports. Never more than once.
|
|
25
|
+
//
|
|
26
|
+
// The open goes to the cloud's front door; every later call goes to Opened.tunnel (else the same
|
|
27
|
+
// address) and carries `authorization: Bearer <token>` from Opened.
|
|
28
|
+
export {};
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/** How a `flow test` ended, one per exit status (0 passed; 1 failed, invalid; 2 errored, transport; 3 refused; 130 interrupted). */
|
|
2
|
+
export type Outcome = "passed" | "failed" | "errored" | "transport" | "refused" | "interrupted" | "invalid";
|
|
3
|
+
export type Verdict = "passed" | "failed" | "errored" | "not_played";
|
|
4
|
+
export interface ReportError {
|
|
5
|
+
message: string;
|
|
6
|
+
/** The cloud's HTTP status, when it answered one. */
|
|
7
|
+
status: number | null;
|
|
8
|
+
/** The cloud's code for it, such as spend_cap or rate_limited. */
|
|
9
|
+
type: string | null;
|
|
10
|
+
retryAfterSeconds: number | null;
|
|
11
|
+
}
|
|
12
|
+
export interface ReportRun {
|
|
13
|
+
runId: string | null;
|
|
14
|
+
/** The scenario's file name without .yaml, as --only takes it. */
|
|
15
|
+
scenario: string;
|
|
16
|
+
channel: string;
|
|
17
|
+
run: number;
|
|
18
|
+
verdict: Verdict;
|
|
19
|
+
/** The cloud's code when it could not play or judge the run (judge_failed, spend_cap, ...); else null. */
|
|
20
|
+
error: string | null;
|
|
21
|
+
ended: string | null;
|
|
22
|
+
turns: number | null;
|
|
23
|
+
watch: string | null;
|
|
24
|
+
failedChecks: Array<{
|
|
25
|
+
kind: string;
|
|
26
|
+
text: string;
|
|
27
|
+
reason: string;
|
|
28
|
+
}>;
|
|
29
|
+
}
|
|
30
|
+
/** `flow test --json`: one document on stdout, whatever the exit. */
|
|
31
|
+
export interface JsonReport {
|
|
32
|
+
version: 1;
|
|
33
|
+
outcome: Outcome;
|
|
34
|
+
exitCode: number;
|
|
35
|
+
sessionId: string | null;
|
|
36
|
+
/** The cloud's id for the agent, which `flow show` reads runs under. */
|
|
37
|
+
agent: string | null;
|
|
38
|
+
dashboard: string | null;
|
|
39
|
+
suiteHash: string | null;
|
|
40
|
+
error: ReportError | null;
|
|
41
|
+
counts: {
|
|
42
|
+
runs: number;
|
|
43
|
+
passed: number;
|
|
44
|
+
failed: number;
|
|
45
|
+
errored: number;
|
|
46
|
+
notPlayed: number;
|
|
47
|
+
};
|
|
48
|
+
costUsd: {
|
|
49
|
+
agent: number;
|
|
50
|
+
test: number;
|
|
51
|
+
};
|
|
52
|
+
runs: ReportRun[];
|
|
53
|
+
}
|
|
54
|
+
/** A run's verdict: errored when the cloud names an error for it or ended it run_failed, since the cloud
|
|
55
|
+
* could not play or judge it and that says nothing about the agent (a judge_failed run can end goal_met). */
|
|
56
|
+
export declare function runVerdict(run: {
|
|
57
|
+
passed: boolean;
|
|
58
|
+
ended: string;
|
|
59
|
+
error?: string;
|
|
60
|
+
}): Exclude<Verdict, "not_played">;
|
|
61
|
+
export declare const EXIT: Record<Outcome, number>;
|
|
62
|
+
/** A session that ran to its summary: failed when a run failed, errored when the cloud could not play or judge a run (or never played it). */
|
|
63
|
+
export declare function finishedOutcome(runs: Array<{
|
|
64
|
+
verdict: Verdict;
|
|
65
|
+
}>): Outcome;
|
|
66
|
+
/** What stopped a session, or the open, as an outcome. `opened`: the session had opened. */
|
|
67
|
+
export declare function stoppedOutcome(error: unknown, opened: boolean): Outcome;
|
|
68
|
+
/** The error as the JSON report says it: the cloud's status, code and Retry-After when it answered. */
|
|
69
|
+
export declare function reportError(error: unknown, message: string): ReportError;
|
|
70
|
+
/** The connection's error code, from the error or what caused it. */
|
|
71
|
+
export declare function networkCode(error: unknown): string | undefined;
|
|
72
|
+
/** The agent folder's .env, or the --dotenv given; variables already set win, and no value is ever printed.
|
|
73
|
+
* Returns what is wrong when a named file cannot be read. */
|
|
74
|
+
export declare function loadEnv(file: string | undefined): string | undefined;
|