@flow-engineer/flow 0.2.3 → 0.3.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 +3 -3
- package/coding-agents/skill.md +20 -7
- package/coding-agents/test-skill.md +50 -1
- package/dist/flow-messaging/src/index.js +8 -5
- package/dist/simulators/src/server.d.ts +2 -0
- package/dist/simulators/src/server.js +7 -1
- package/dist/src/agent.d.ts +32 -0
- package/dist/src/agent.js +38 -8
- package/dist/src/channels.d.ts +2 -0
- package/dist/src/channels.js +1 -0
- package/dist/src/conversation.d.ts +2 -0
- package/dist/src/conversation.js +8 -2
- package/dist/src/http.js +5 -2
- package/dist/src/index.d.ts +1 -0
- package/dist/src/index.js +1 -0
- package/dist/src/local.d.ts +22 -0
- package/dist/src/local.js +85 -0
- package/dist/src/types.d.ts +6 -0
- package/dist/testing/src/account.d.ts +1 -1
- package/dist/testing/src/account.js +78 -20
- package/dist/testing/src/business.d.ts +42 -0
- package/dist/testing/src/business.js +145 -0
- package/dist/testing/src/cli.js +10 -8
- package/dist/testing/src/client.d.ts +4 -2
- package/dist/testing/src/client.js +20 -0
- package/dist/testing/src/index.d.ts +2 -2
- package/dist/testing/src/index.js +1 -1
- package/dist/testing/src/new.d.ts +9 -0
- package/dist/testing/src/new.js +44 -5
- package/dist/testing/src/protocol.d.ts +99 -1
- package/dist/testing/src/protocol.js +9 -0
- package/dist/testing/src/suite.d.ts +142 -3
- package/dist/testing/src/suite.js +94 -10
- package/docs/agents.md +31 -9
- package/examples/cafe/src/main.ts +23 -8
- package/examples/cafe/src/mcp.ts +24 -6
- package/package.json +1 -1
package/dist/testing/src/new.js
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
|
-
// `flow new <name>`: makes ./<name>/, an agent of its own started from the café example
|
|
1
|
+
// `flow new <name>`: makes ./<name>/, an agent of its own started from the café example,
|
|
2
2
|
// that depends on the @flow-engineer/flow package. Its imports become "@flow-engineer/flow",
|
|
3
3
|
// "@flow-engineer/flow/simulators" and "@flow-engineer/flow/flow-messaging", and the coding-agent
|
|
4
4
|
// skills go in .claude/skills/, with an AGENTS.md (and a CLAUDE.md importing it) pointing at them.
|
|
5
5
|
// `flow init` makes the same agent in the current folder. Inside a clone, `npm run new -- <name>`
|
|
6
6
|
// makes agents/<name>/ instead. `--display-name` is what people call the agent ("FlatX agent"), kept
|
|
7
7
|
// as package.json's displayName: the message a phone sends to reach it is "Hi <it> (ref <code>)".
|
|
8
|
+
// The café's agent is Pip; the display name takes Pip's place in its prompt (agent/AGENTS.md), and
|
|
9
|
+
// its playground and MCP server read the name from package.json. `flow link --display-name` (and
|
|
10
|
+
// `setup`) renames an agent made already, the same way (setDisplayName).
|
|
8
11
|
import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
9
12
|
import { basename, dirname, join } from "node:path";
|
|
10
13
|
import { fileURLToPath } from "node:url";
|
|
@@ -66,13 +69,15 @@ FLOW_FRAMEWORK_TRACES_KEY=
|
|
|
66
69
|
`;
|
|
67
70
|
const agentsMd = (name) => `# ${name}
|
|
68
71
|
|
|
69
|
-
A chat agent on Flow Framework (https://github.com/flow-engineer/flow), started from its café example
|
|
72
|
+
A chat agent on Flow Framework (https://github.com/flow-engineer/flow), started from its café example.
|
|
70
73
|
|
|
71
74
|
- Load the skills in \`.claude/skills/\` before changing it: \`flow-framework\` to build the agent (and its MCP
|
|
72
75
|
server and CLI), \`flow-framework-tools\` for its tools and their test files, \`flow-framework-tests\` for its test
|
|
73
76
|
scenarios, \`flow-framework-context\` to gather what the tools are written from out of an existing
|
|
74
77
|
codebase, and \`flow-framework-auth\` for sign-in.
|
|
75
|
-
- \`agent/AGENTS.md\` is the agent's own prompt, not instructions for coding agents.
|
|
78
|
+
- \`agent/AGENTS.md\` is the agent's own prompt, not instructions for coding agents. What people call
|
|
79
|
+
the agent is package.json's \`displayName\`; \`npx @flow-engineer/flow link --display-name <name>\`
|
|
80
|
+
changes it there and in that prompt.
|
|
76
81
|
- The model key goes in \`.env\` (never read or print it): \`npx @flow-engineer/flow link\` (or \`setup\`)
|
|
77
82
|
writes \`FLOW_KEY\`, the agent's one Flow key, which calls models on Flow's free trial credit, so no provider key is needed; a key the owner types
|
|
78
83
|
(\`OPENROUTER_API_KEY\`, \`OPENAI_API_KEY\`, \`ANTHROPIC_API_KEY\` or \`GEMINI_API_KEY\`) wins over it. \`npm start\` runs it in the playground,
|
|
@@ -97,6 +102,39 @@ export function displayNameOf(given, packageName) {
|
|
|
97
102
|
throw new Error(`--display-name is at most ${MAX_DISPLAY_NAME} characters, such as "FlatX agent".`);
|
|
98
103
|
return name || packageName.replace(/-/g, " ");
|
|
99
104
|
}
|
|
105
|
+
/** The café's name for its agent, in its prompt; a new agent's display name takes its place. */
|
|
106
|
+
const TEMPLATE_NAME = "Pip";
|
|
107
|
+
/** Puts `to` in place of each of `from` (whole words, the longest first, in one pass) in the agent's prompt; true when it changed. */
|
|
108
|
+
function renameInPrompt(dir, from, to) {
|
|
109
|
+
const file = join(dir, "agent", "AGENTS.md");
|
|
110
|
+
const names = [...new Set(from.filter((n) => n && n !== to))].sort((a, b) => b.length - a.length);
|
|
111
|
+
if (!names.length || !existsSync(file))
|
|
112
|
+
return false;
|
|
113
|
+
const text = readFileSync(file, "utf8");
|
|
114
|
+
const word = new RegExp(`(?<![\\p{L}\\p{N}_])(?:${names.map((n) => n.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|")})(?![\\p{L}\\p{N}_])`, "gu");
|
|
115
|
+
const renamed = text.replace(word, () => to);
|
|
116
|
+
if (renamed === text)
|
|
117
|
+
return false;
|
|
118
|
+
writeFileSync(file, renamed);
|
|
119
|
+
return true;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Renames the agent in `dir`: package.json's displayName, and in its prompt the old display name
|
|
123
|
+
* (and the café's Pip) become the new one. Returns the name and whether the prompt changed (a prompt
|
|
124
|
+
* rewritten by hand may name neither).
|
|
125
|
+
*/
|
|
126
|
+
export function setDisplayName(dir, given) {
|
|
127
|
+
const file = join(dir, "package.json");
|
|
128
|
+
const pkg = JSON.parse(readFileSync(file, "utf8"));
|
|
129
|
+
const displayName = displayNameOf(given, pkg.name ?? folderName(dir));
|
|
130
|
+
const before = typeof pkg.displayName === "string" ? pkg.displayName : "";
|
|
131
|
+
if (before !== displayName) {
|
|
132
|
+
// displayName stays right after name, where flow init puts it.
|
|
133
|
+
const { name, displayName: _, ...rest } = pkg;
|
|
134
|
+
writeFileSync(file, `${JSON.stringify({ ...(name !== undefined ? { name } : {}), displayName, ...rest }, null, 2)}\n`);
|
|
135
|
+
}
|
|
136
|
+
return { displayName, prompt: renameInPrompt(dir, [before, TEMPLATE_NAME], displayName) };
|
|
137
|
+
}
|
|
100
138
|
/** Makes `<into>/<name>` (the current folder by default) and returns it. */
|
|
101
139
|
export function newStandalone(name, options = {}) {
|
|
102
140
|
checkName(name);
|
|
@@ -126,6 +164,7 @@ function makeAgent(dir, name, displayName, root) {
|
|
|
126
164
|
const cafe = join(root, "examples", "cafe");
|
|
127
165
|
for (const part of ["agent", "src", "tests"])
|
|
128
166
|
cpSync(join(cafe, part), join(dir, part), { recursive: true });
|
|
167
|
+
renameInPrompt(dir, [TEMPLATE_NAME], displayName);
|
|
129
168
|
for (const file of readdirSync(join(dir, "src"), { recursive: true, encoding: "utf8" }).filter((f) => f.endsWith(".ts"))) {
|
|
130
169
|
const path = join(dir, "src", file);
|
|
131
170
|
const source = readFileSync(path, "utf8")
|
|
@@ -134,8 +173,8 @@ function makeAgent(dir, name, displayName, root) {
|
|
|
134
173
|
.replace(/(["'])(\.\.\/)+flow-messaging\/src\/index\.ts\1/g, '"@flow-engineer/flow/flow-messaging"')
|
|
135
174
|
.replace(/(["'])(\.\.\/)+mcp\/src\/index\.ts\1/g, '"@flow-engineer/flow/mcp"')
|
|
136
175
|
.replace(/(["'])(\.\.\/)+cli\/src\/index\.ts\1/g, '"@flow-engineer/flow/cli"');
|
|
137
|
-
// The CLI is the agent's own command, named after it.
|
|
138
|
-
const text = file === "cli.ts" ? source.replace(/name: "cafe"/, `name: "${name}"`).replace(/CAFE_SERVER/g, `${name.toUpperCase().replace(/-/g, "_")}_SERVER`)
|
|
176
|
+
// The CLI is the agent's own command, named after it (and so is its server's setting, which mcp.ts names too).
|
|
177
|
+
const text = (file === "cli.ts" ? source.replace(/name: "cafe"/, `name: "${name}"`) : source).replace(/CAFE_SERVER/g, `${name.toUpperCase().replace(/-/g, "_")}_SERVER`);
|
|
139
178
|
if (/["']\.\.\/\.\.\//.test(text))
|
|
140
179
|
throw new Error(`flow: ${file} imports a file outside the agent; only Flow Framework's public entries can be rewritten.`);
|
|
141
180
|
writeFileSync(path, text);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Attachment, ChannelInfo, ChannelSummary, Incoming, ToolSpec } from "../../src/index.ts";
|
|
1
|
+
import type { Attachment, ChannelInfo, ChannelSummary, Incoming, Outbound, Template, ToolSpec } from "../../src/index.ts";
|
|
2
2
|
/** One graded expect item of a run. */
|
|
3
3
|
export interface Check {
|
|
4
4
|
kind: "outcome" | "judge";
|
|
@@ -35,6 +35,8 @@ export interface OpenSession {
|
|
|
35
35
|
resume?: boolean;
|
|
36
36
|
/** The client opens one new session for the unfinished runs when this cloud says session_gone. */
|
|
37
37
|
reopen?: boolean;
|
|
38
|
+
/** The client plays business runs (open, event, nudge, advance, close). */
|
|
39
|
+
business?: boolean;
|
|
38
40
|
/** Play only these runs, numbered as given: a re-open after session_gone. Sent only to a cloud whose Opened said reopen,
|
|
39
41
|
* and only when some run finished: the cloud checks it against the suite's files it already stores. */
|
|
40
42
|
only?: RunKey[];
|
|
@@ -67,6 +69,8 @@ export interface Opened {
|
|
|
67
69
|
resume?: boolean;
|
|
68
70
|
/** This cloud answers session_gone for a session it lost, and plays `only` on an open. Absent on an older cloud. */
|
|
69
71
|
reopen?: boolean;
|
|
72
|
+
/** This cloud plays business runs; the client sent `business`. Absent on an older cloud. */
|
|
73
|
+
business?: boolean;
|
|
70
74
|
}
|
|
71
75
|
export interface UploadFile {
|
|
72
76
|
hash: string;
|
|
@@ -98,7 +102,101 @@ export type Command = {
|
|
|
98
102
|
| {
|
|
99
103
|
type: "failed";
|
|
100
104
|
error: string;
|
|
105
|
+
} | BusinessCommand;
|
|
106
|
+
/**
|
|
107
|
+
* A business run's commands. `step` numbers the run's business commands from 1 in the order the
|
|
108
|
+
* cloud sends them (`open` is 0; a step with `at:` is an `advance` and then its own command); its
|
|
109
|
+
* StepAnswer carries the same number. The client treats it as opaque.
|
|
110
|
+
*/
|
|
111
|
+
export type BusinessCommand =
|
|
112
|
+
/** A run starts: build its agent with its clock at `now` (epoch ms). The person is `address` on `channelName`;
|
|
113
|
+
* `realTools` run the agent's own code in place of the cloud's simulation. */
|
|
114
|
+
{
|
|
115
|
+
type: "open";
|
|
116
|
+
conversationId: string;
|
|
117
|
+
channelName: string;
|
|
118
|
+
channel: ChannelInfo;
|
|
119
|
+
address: string;
|
|
120
|
+
now: number;
|
|
121
|
+
realTools?: string[];
|
|
122
|
+
}
|
|
123
|
+
/** The business's backend sends an event: POST `path` (default /v1/events) to the agent's own routes with this
|
|
124
|
+
* envelope as the body, signed with the run's test secret (`signature` bad: with another secret; missing: no header). */
|
|
125
|
+
| {
|
|
126
|
+
type: "event";
|
|
127
|
+
conversationId: string;
|
|
128
|
+
step: number;
|
|
129
|
+
envelope: FlowEventEnvelope;
|
|
130
|
+
path?: string;
|
|
131
|
+
signature?: "bad" | "missing";
|
|
132
|
+
}
|
|
133
|
+
/** The business nudges the run's person. */
|
|
134
|
+
| {
|
|
135
|
+
type: "nudge";
|
|
136
|
+
conversationId: string;
|
|
137
|
+
step: number;
|
|
138
|
+
nudge: BusinessNudge;
|
|
139
|
+
}
|
|
140
|
+
/** Time passes: the clock moves to `to` (epoch ms), a minute at a time, and what came due runs at each step. */
|
|
141
|
+
| {
|
|
142
|
+
type: "advance";
|
|
143
|
+
conversationId: string;
|
|
144
|
+
step: number;
|
|
145
|
+
to: number;
|
|
146
|
+
}
|
|
147
|
+
/** The run is over: its agent is dropped. */
|
|
148
|
+
| {
|
|
149
|
+
type: "close";
|
|
150
|
+
conversationId: string;
|
|
101
151
|
};
|
|
152
|
+
/** A Flow event as a business's backend sends it: one envelope for every type. */
|
|
153
|
+
export interface FlowEventEnvelope {
|
|
154
|
+
/** Unique per event; the agent processes one id once. */
|
|
155
|
+
id: string;
|
|
156
|
+
type: string;
|
|
157
|
+
/** ISO 8601. */
|
|
158
|
+
occurredAt: string;
|
|
159
|
+
/** What it is about: "user:<id>", "listing:<slug>". */
|
|
160
|
+
subject: string;
|
|
161
|
+
/** How binding a send it asks for, e.g. critical, standard or optional; the agent decides what each means. */
|
|
162
|
+
delivery?: string;
|
|
163
|
+
data: Record<string, unknown>;
|
|
164
|
+
}
|
|
165
|
+
/** A nudge as a scenario writes it; `to` is the run's person. */
|
|
166
|
+
export interface BusinessNudge {
|
|
167
|
+
text?: string;
|
|
168
|
+
prompt?: string;
|
|
169
|
+
template?: Template;
|
|
170
|
+
respectQuietHours?: boolean;
|
|
171
|
+
/** Epoch ms. */
|
|
172
|
+
expiresAt?: number;
|
|
173
|
+
idempotencyKey?: string;
|
|
174
|
+
}
|
|
175
|
+
/** What one business step came to. */
|
|
176
|
+
export interface StepAnswer {
|
|
177
|
+
conversationId: string;
|
|
178
|
+
step: number;
|
|
179
|
+
/** The agent's HTTP answer to an event. */
|
|
180
|
+
answer?: {
|
|
181
|
+
status: number;
|
|
182
|
+
body: unknown;
|
|
183
|
+
};
|
|
184
|
+
/** A nudge's result. */
|
|
185
|
+
nudge?: {
|
|
186
|
+
status: string;
|
|
187
|
+
reason?: string;
|
|
188
|
+
};
|
|
189
|
+
/** The run's clock when the step settled, epoch ms. */
|
|
190
|
+
at: number;
|
|
191
|
+
/** The step could not be played on the agent's side (the agent threw, no such route ...). */
|
|
192
|
+
error?: string;
|
|
193
|
+
}
|
|
194
|
+
/** A business run's outbound: what the agent sent, and when on the run's clock. */
|
|
195
|
+
export interface BusinessOutbound {
|
|
196
|
+
conversationId: string;
|
|
197
|
+
outbound: Outbound;
|
|
198
|
+
at: number;
|
|
199
|
+
}
|
|
102
200
|
/**
|
|
103
201
|
* A person's message as it travels: a scenario's file goes by its path in the tests folder, and the
|
|
104
202
|
* agent's side reads it there, so the file never leaves the developer's machine.
|
|
@@ -13,6 +13,15 @@
|
|
|
13
13
|
// says idempotency, a repeat of a key is the stored answer and the client may send it again.
|
|
14
14
|
// POST /api/sessions/:id/receipts Receipt → { ok }
|
|
15
15
|
// POST /api/sessions/:id/drained {} → { ok } flush before the terminal command
|
|
16
|
+
// POST /api/sessions/:id/steps StepAnswer → { ok } a business run's step done (below)
|
|
17
|
+
//
|
|
18
|
+
// Business runs (both sides said `business`): a scenario the business starts, with an event its
|
|
19
|
+
// backend sends, a nudge, or time passing on the test's clock. Each such run gets an agent of its
|
|
20
|
+
// own on the agent's side, built with a clock the commands move (its own `now`, no schedule timer),
|
|
21
|
+
// and the agent's own channels, whose sends come to the cloud as an outbound with `at`, the run's
|
|
22
|
+
// clock. The cloud sends `open` first, then any of `message`, `reaction`, `event`, `nudge`,
|
|
23
|
+
// `advance`, and `close` when the run is over. `open`, `event`, `nudge` and `advance` are each
|
|
24
|
+
// answered by one StepAnswer once the agent has settled: everything they set off has been sent.
|
|
16
25
|
//
|
|
17
26
|
// When Opened says resume, every command on the stream carries `id: N` (1, 2, 3, ... per session).
|
|
18
27
|
// A stream that drops, or sits silent past the idle watch, is opened again with Last-Event-ID set
|
|
@@ -21,6 +21,15 @@ declare const expectedCall: z.ZodUnion<readonly [z.ZodString, z.ZodObject<{
|
|
|
21
21
|
tool: z.ZodString;
|
|
22
22
|
args: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
|
23
23
|
}, z.core.$strict>]>;
|
|
24
|
+
/** A wait's length in ms, or null when it is not one. */
|
|
25
|
+
export declare function waitMs(text: string): number | null;
|
|
26
|
+
/** One thing the agent sent (`expect.sent`), or must not have (`expect.not_sent`; `{}` is anything at all). */
|
|
27
|
+
declare const sentItem: z.ZodObject<{
|
|
28
|
+
at: z.ZodOptional<z.ZodString>;
|
|
29
|
+
template: z.ZodOptional<z.ZodString>;
|
|
30
|
+
phrased: z.ZodOptional<z.ZodLiteral<true>>;
|
|
31
|
+
text: z.ZodOptional<z.ZodString>;
|
|
32
|
+
}, z.core.$strict>;
|
|
24
33
|
declare const scenarioSchema: z.ZodObject<{
|
|
25
34
|
id: z.ZodString;
|
|
26
35
|
title: z.ZodOptional<z.ZodString>;
|
|
@@ -34,12 +43,13 @@ declare const scenarioSchema: z.ZodObject<{
|
|
|
34
43
|
timezone: z.ZodOptional<z.ZodString>;
|
|
35
44
|
now: z.ZodOptional<z.ZodPreprocess<z.ZodString, unknown>>;
|
|
36
45
|
person: z.ZodObject<{
|
|
46
|
+
address: z.ZodOptional<z.ZodString>;
|
|
37
47
|
persona: z.ZodString;
|
|
38
48
|
goal: z.ZodString;
|
|
39
49
|
knows: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
40
50
|
}, z.core.$strip>;
|
|
41
|
-
opening: z.ZodArray<z.ZodObject<{
|
|
42
|
-
say: z.
|
|
51
|
+
opening: z.ZodArray<z.ZodPipe<z.ZodObject<{
|
|
52
|
+
say: z.ZodOptional<z.ZodString>;
|
|
43
53
|
attach: z.ZodOptional<z.ZodObject<{
|
|
44
54
|
kind: z.ZodEnum<{
|
|
45
55
|
audio: "audio";
|
|
@@ -49,7 +59,120 @@ declare const scenarioSchema: z.ZodObject<{
|
|
|
49
59
|
file: z.ZodString;
|
|
50
60
|
}, z.core.$strip>>;
|
|
51
61
|
after_ms: z.ZodOptional<z.ZodNumber>;
|
|
52
|
-
|
|
62
|
+
event: z.ZodOptional<z.ZodObject<{
|
|
63
|
+
id: z.ZodOptional<z.ZodString>;
|
|
64
|
+
type: z.ZodString;
|
|
65
|
+
occurredAt: z.ZodOptional<z.ZodString>;
|
|
66
|
+
subject: z.ZodString;
|
|
67
|
+
delivery: z.ZodOptional<z.ZodString>;
|
|
68
|
+
data: z.ZodRecord<z.ZodString, z.ZodUnknown>;
|
|
69
|
+
}, z.core.$strict>>;
|
|
70
|
+
path: z.ZodOptional<z.ZodString>;
|
|
71
|
+
signature: z.ZodOptional<z.ZodEnum<{
|
|
72
|
+
bad: "bad";
|
|
73
|
+
missing: "missing";
|
|
74
|
+
}>>;
|
|
75
|
+
answer: z.ZodOptional<z.ZodUnion<readonly [z.ZodNumber, z.ZodObject<{
|
|
76
|
+
status: z.ZodNumber;
|
|
77
|
+
field: z.ZodOptional<z.ZodString>;
|
|
78
|
+
}, z.core.$strict>]>>;
|
|
79
|
+
nudge: z.ZodOptional<z.ZodObject<{
|
|
80
|
+
text: z.ZodOptional<z.ZodString>;
|
|
81
|
+
prompt: z.ZodOptional<z.ZodString>;
|
|
82
|
+
template: z.ZodOptional<z.ZodObject<{
|
|
83
|
+
name: z.ZodString;
|
|
84
|
+
language: z.ZodOptional<z.ZodString>;
|
|
85
|
+
body: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
86
|
+
buttons: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
87
|
+
index: z.ZodNumber;
|
|
88
|
+
value: z.ZodString;
|
|
89
|
+
}, z.core.$strict>>>;
|
|
90
|
+
}, z.core.$strict>>;
|
|
91
|
+
respectQuietHours: z.ZodOptional<z.ZodBoolean>;
|
|
92
|
+
expiresAt: z.ZodOptional<z.ZodString>;
|
|
93
|
+
}, z.core.$strict>>;
|
|
94
|
+
wait: z.ZodOptional<z.ZodString>;
|
|
95
|
+
at: z.ZodOptional<z.ZodString>;
|
|
96
|
+
}, z.core.$strip>, z.ZodTransform<{
|
|
97
|
+
say?: string | undefined;
|
|
98
|
+
attach?: {
|
|
99
|
+
kind: "audio" | "document" | "image";
|
|
100
|
+
file: string;
|
|
101
|
+
} | undefined;
|
|
102
|
+
after_ms?: number | undefined;
|
|
103
|
+
event?: {
|
|
104
|
+
id?: string | undefined;
|
|
105
|
+
type: string;
|
|
106
|
+
occurredAt?: string | undefined;
|
|
107
|
+
subject: string;
|
|
108
|
+
delivery?: string | undefined;
|
|
109
|
+
data: Record<string, unknown>;
|
|
110
|
+
} | undefined;
|
|
111
|
+
path?: string | undefined;
|
|
112
|
+
signature?: "bad" | "missing" | undefined;
|
|
113
|
+
answer?: number | {
|
|
114
|
+
status: number;
|
|
115
|
+
field?: string | undefined;
|
|
116
|
+
} | undefined;
|
|
117
|
+
nudge?: {
|
|
118
|
+
text?: string | undefined;
|
|
119
|
+
prompt?: string | undefined;
|
|
120
|
+
template?: {
|
|
121
|
+
name: string;
|
|
122
|
+
language?: string | undefined;
|
|
123
|
+
body?: string[] | undefined;
|
|
124
|
+
buttons?: {
|
|
125
|
+
index: number;
|
|
126
|
+
value: string;
|
|
127
|
+
}[] | undefined;
|
|
128
|
+
} | undefined;
|
|
129
|
+
respectQuietHours?: boolean | undefined;
|
|
130
|
+
expiresAt?: string | undefined;
|
|
131
|
+
} | undefined;
|
|
132
|
+
wait?: string | undefined;
|
|
133
|
+
at?: string | undefined;
|
|
134
|
+
}, {
|
|
135
|
+
say?: string | undefined;
|
|
136
|
+
attach?: {
|
|
137
|
+
kind: "audio" | "document" | "image";
|
|
138
|
+
file: string;
|
|
139
|
+
} | undefined;
|
|
140
|
+
after_ms?: number | undefined;
|
|
141
|
+
event?: {
|
|
142
|
+
id?: string | undefined;
|
|
143
|
+
type: string;
|
|
144
|
+
occurredAt?: string | undefined;
|
|
145
|
+
subject: string;
|
|
146
|
+
delivery?: string | undefined;
|
|
147
|
+
data: Record<string, unknown>;
|
|
148
|
+
} | undefined;
|
|
149
|
+
path?: string | undefined;
|
|
150
|
+
signature?: "bad" | "missing" | undefined;
|
|
151
|
+
answer?: number | {
|
|
152
|
+
status: number;
|
|
153
|
+
field?: string | undefined;
|
|
154
|
+
} | undefined;
|
|
155
|
+
nudge?: {
|
|
156
|
+
text?: string | undefined;
|
|
157
|
+
prompt?: string | undefined;
|
|
158
|
+
template?: {
|
|
159
|
+
name: string;
|
|
160
|
+
language?: string | undefined;
|
|
161
|
+
body?: string[] | undefined;
|
|
162
|
+
buttons?: {
|
|
163
|
+
index: number;
|
|
164
|
+
value: string;
|
|
165
|
+
}[] | undefined;
|
|
166
|
+
} | undefined;
|
|
167
|
+
respectQuietHours?: boolean | undefined;
|
|
168
|
+
expiresAt?: string | undefined;
|
|
169
|
+
} | undefined;
|
|
170
|
+
wait?: string | undefined;
|
|
171
|
+
at?: string | undefined;
|
|
172
|
+
}>>>;
|
|
173
|
+
tools: z.ZodOptional<z.ZodObject<{
|
|
174
|
+
real: z.ZodArray<z.ZodString>;
|
|
175
|
+
}, z.core.$strict>>;
|
|
53
176
|
world: z.ZodOptional<z.ZodString>;
|
|
54
177
|
max_turns: z.ZodDefault<z.ZodNumber>;
|
|
55
178
|
expect: z.ZodDefault<z.ZodObject<{
|
|
@@ -63,6 +186,18 @@ declare const scenarioSchema: z.ZodObject<{
|
|
|
63
186
|
tool: z.ZodString;
|
|
64
187
|
args: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
|
65
188
|
}, z.core.$strict>]>>>;
|
|
189
|
+
sent: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
190
|
+
at: z.ZodOptional<z.ZodString>;
|
|
191
|
+
template: z.ZodOptional<z.ZodString>;
|
|
192
|
+
phrased: z.ZodOptional<z.ZodLiteral<true>>;
|
|
193
|
+
text: z.ZodOptional<z.ZodString>;
|
|
194
|
+
}, z.core.$strict>>>;
|
|
195
|
+
not_sent: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
196
|
+
at: z.ZodOptional<z.ZodString>;
|
|
197
|
+
template: z.ZodOptional<z.ZodString>;
|
|
198
|
+
phrased: z.ZodOptional<z.ZodLiteral<true>>;
|
|
199
|
+
text: z.ZodOptional<z.ZodString>;
|
|
200
|
+
}, z.core.$strict>>>;
|
|
66
201
|
}, z.core.$strip>>;
|
|
67
202
|
}, z.core.$strip>;
|
|
68
203
|
declare const toolFileSchema: z.ZodObject<{
|
|
@@ -79,6 +214,10 @@ declare const toolFileSchema: z.ZodObject<{
|
|
|
79
214
|
/** The type of a file a scenario attaches, from its extension. */
|
|
80
215
|
export declare function mimeOf(path: string): string;
|
|
81
216
|
export type Scenario = z.infer<typeof scenarioSchema>;
|
|
217
|
+
export type OpeningStep = Scenario["opening"][number];
|
|
218
|
+
export type SentItem = z.infer<typeof sentItem>;
|
|
219
|
+
/** The business starts something in it (an event, a nudge, time passing), or it checks what was sent: a business run. */
|
|
220
|
+
export declare function isBusiness(scenario: Scenario): boolean;
|
|
82
221
|
export type ToolFile = z.infer<typeof toolFileSchema>;
|
|
83
222
|
export type ExpectedCall = z.infer<typeof expectedCall>;
|
|
84
223
|
/** Parse and check a scenario. The error has one line per problem: `file:line: field: what is wrong`. */
|
|
@@ -87,6 +87,87 @@ const expectedCall = z.union([
|
|
|
87
87
|
args: withClock(z.record(z.string(), z.unknown())).optional(),
|
|
88
88
|
}),
|
|
89
89
|
], { error: "a tool name, or { tool: <name>, args: { <field>: <value> } }" });
|
|
90
|
+
/** "8h30m", "2d", "45m": how long a `wait` step lets pass. */
|
|
91
|
+
const WAIT = /^(?:(\d+)d)?(?:(\d+)h)?(?:(\d+)m)?$/;
|
|
92
|
+
/** A wait's length in ms, or null when it is not one. */
|
|
93
|
+
export function waitMs(text) {
|
|
94
|
+
const m = WAIT.exec(text.trim());
|
|
95
|
+
if (!m || !(m[1] || m[2] || m[3]))
|
|
96
|
+
return null;
|
|
97
|
+
const ms = (Number(m[1] ?? 0) * 24 * 60 + Number(m[2] ?? 0) * 60 + Number(m[3] ?? 0)) * 60_000;
|
|
98
|
+
return ms > 0 ? ms : null;
|
|
99
|
+
}
|
|
100
|
+
/** A moment on the test's clock: a date and time (local to the scenario's timezone unless it has an offset),
|
|
101
|
+
* HH:MM (the next time the clock shows it), or a `{{now ...}}` placeholder. */
|
|
102
|
+
const moment = z.string().refine((t) => isDateTime(t) || /^([01]\d|2[0-3]):[0-5]\d$/.test(t) || (/^\s*\{\{[\s\S]*\}\}\s*$/.test(t) && placeholderProblem(t) === null), "a time on the test's clock: 2026-10-20T23:30, 2026-10-20T23:30+05:30, 23:30, or {{now + 1d @ 08:00}}");
|
|
103
|
+
const template = z.strictObject({
|
|
104
|
+
name: z.string().min(1),
|
|
105
|
+
language: z.string().optional(),
|
|
106
|
+
body: z.array(z.string()).optional(),
|
|
107
|
+
buttons: z.array(z.strictObject({ index: z.number().int().min(0), value: z.string() })).optional(),
|
|
108
|
+
});
|
|
109
|
+
/** A Flow event as the business's backend sends it; `id` and `occurredAt` default to one per step and the clock. */
|
|
110
|
+
const flowEvent = withClock(z.strictObject({
|
|
111
|
+
id: z.string().min(1).optional(),
|
|
112
|
+
type: z.string().min(1),
|
|
113
|
+
occurredAt: z.string().optional(),
|
|
114
|
+
subject: z.string().min(1),
|
|
115
|
+
delivery: z.string().optional(),
|
|
116
|
+
data: z.record(z.string(), z.unknown()),
|
|
117
|
+
}));
|
|
118
|
+
const businessNudge = withClock(z
|
|
119
|
+
.strictObject({
|
|
120
|
+
text: z.string().min(1).optional(),
|
|
121
|
+
prompt: z.string().min(1).optional(),
|
|
122
|
+
template: template.optional(),
|
|
123
|
+
respectQuietHours: z.boolean().optional(),
|
|
124
|
+
expiresAt: moment.optional(),
|
|
125
|
+
})
|
|
126
|
+
.refine((n) => [n.text, n.prompt, n.template].filter((v) => v !== undefined).length === 1, "exactly one of text, prompt or template"));
|
|
127
|
+
/** What the agent's route must answer an event: a status, or a status and the field a 400 names. */
|
|
128
|
+
const answer = z.union([z.number().int().min(100).max(599), z.strictObject({ status: z.number().int().min(100).max(599), field: z.string().min(1).optional() })]);
|
|
129
|
+
/** One thing the agent sent (`expect.sent`), or must not have (`expect.not_sent`; `{}` is anything at all). */
|
|
130
|
+
const sentItem = z.strictObject({
|
|
131
|
+
/** When, on the test's clock: HH:MM (any day), or a date and time; to the minute. */
|
|
132
|
+
at: moment.optional(),
|
|
133
|
+
/** A template by name. */
|
|
134
|
+
template: z.string().min(1).optional(),
|
|
135
|
+
/** The agent's own words rather than a template. */
|
|
136
|
+
phrased: z.literal(true).optional(),
|
|
137
|
+
/** Text the message (or the template's values) holds. */
|
|
138
|
+
text: withClock(z.string().min(1)).optional(),
|
|
139
|
+
});
|
|
140
|
+
const openingStep = z
|
|
141
|
+
.object({
|
|
142
|
+
say: withClock(z.string()).optional(),
|
|
143
|
+
/** A file the person sends with it (say is its caption): a path in the tests folder. */
|
|
144
|
+
attach: z.object({ kind: z.enum(["image", "audio", "document"]), file: z.string().refine(inTests, "a path inside the tests folder, such as fixtures/receipt.jpg") }).optional(),
|
|
145
|
+
after_ms: z.number().int().min(0).optional(),
|
|
146
|
+
/** The business's backend sends this event, to the agent's own route. */
|
|
147
|
+
event: flowEvent.optional(),
|
|
148
|
+
/** The route the event goes to; /v1/events when left out. */
|
|
149
|
+
path: z.string().regex(/^\/[^\s?#]*$/, "a path such as /v1/events").optional(),
|
|
150
|
+
/** bad: signed with another secret; missing: no Flow-Signature header. */
|
|
151
|
+
signature: z.enum(["bad", "missing"]).optional(),
|
|
152
|
+
/** What the route must answer; anything 2xx when left out. */
|
|
153
|
+
answer: answer.optional(),
|
|
154
|
+
/** The business nudges the person. */
|
|
155
|
+
nudge: businessNudge.optional(),
|
|
156
|
+
/** Time passes on the test's clock. */
|
|
157
|
+
wait: z.string().refine((t) => waitMs(t) !== null, 'a length of time such as 8h30m, 45m or 2d').optional(),
|
|
158
|
+
/** Move the test's clock to this time before the step. */
|
|
159
|
+
at: moment.optional(),
|
|
160
|
+
})
|
|
161
|
+
.superRefine((step, ctx) => {
|
|
162
|
+
const kinds = [step.say !== undefined || step.attach ? "say" : null, step.event ? "event" : null, step.nudge ? "nudge" : null, step.wait !== undefined ? "wait" : null].filter(Boolean);
|
|
163
|
+
if (kinds.length !== 1)
|
|
164
|
+
ctx.addIssue({ code: "custom", message: kinds.length ? `one of say, event, nudge or wait per step, not ${kinds.join(" and ")}` : "say something, attach a file, send an event, nudge, or wait" });
|
|
165
|
+
if (!step.event && (step.path || step.signature || step.answer !== undefined))
|
|
166
|
+
ctx.addIssue({ code: "custom", message: "path, signature and answer belong to an event step" });
|
|
167
|
+
if (kinds[0] === "say" && !step.say && !step.attach)
|
|
168
|
+
ctx.addIssue({ code: "custom", message: "say something, attach a file, or both" });
|
|
169
|
+
})
|
|
170
|
+
.transform((step) => (step.event || step.nudge || step.wait !== undefined ? step : { ...step, say: step.say ?? "" }));
|
|
90
171
|
const scenarioSchema = z.object({
|
|
91
172
|
id: z.string().min(1),
|
|
92
173
|
title: z.string().optional(),
|
|
@@ -100,20 +181,15 @@ const scenarioSchema = z.object({
|
|
|
100
181
|
.preprocess((v) => (v instanceof Date ? v.toISOString().replace(/\.\d+Z$/, "Z") : v), z.string().refine(isDateTime, "not a date and time: write YYYY-MM-DDTHH:MM, with :SS, Z or an offset such as -04:00 if needed"))
|
|
101
182
|
.optional(),
|
|
102
183
|
person: z.object({
|
|
184
|
+
/** Their address on the run's channel (a phone number on phone channels): who a business run's events and nudges are for. */
|
|
185
|
+
address: z.string().min(1).optional(),
|
|
103
186
|
persona: withClock(z.string()),
|
|
104
187
|
goal: withClock(z.string()),
|
|
105
188
|
knows: z.array(withClock(z.string())).optional(),
|
|
106
189
|
}),
|
|
107
|
-
opening: z
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
say: withClock(z.string()).default(""),
|
|
111
|
-
/** A file the person sends with it (say is its caption): a path in the tests folder. */
|
|
112
|
-
attach: z.object({ kind: z.enum(["image", "audio", "document"]), file: z.string().refine(inTests, "a path inside the tests folder, such as fixtures/receipt.jpg") }).optional(),
|
|
113
|
-
after_ms: z.number().int().min(0).optional(),
|
|
114
|
-
})
|
|
115
|
-
.refine((step) => step.say || step.attach, "say something, attach a file, or both"))
|
|
116
|
-
.min(1),
|
|
190
|
+
opening: z.array(openingStep).min(1),
|
|
191
|
+
/** Tools whose own code runs in this scenario, in place of the cloud's simulation (state the agent keeps itself). */
|
|
192
|
+
tools: z.strictObject({ real: z.array(z.string().min(1)).min(1) }).optional(),
|
|
117
193
|
world: withClock(z.string()).optional(),
|
|
118
194
|
max_turns: z.number().int().min(1).max(50).default(8),
|
|
119
195
|
expect: z
|
|
@@ -124,6 +200,10 @@ const scenarioSchema = z.object({
|
|
|
124
200
|
calls: z.array(expectedCall).optional(),
|
|
125
201
|
/** None may be called. */
|
|
126
202
|
never: z.array(expectedCall).optional(),
|
|
203
|
+
/** What the agent sent, in order (other sends may come between): checked by code, with the test's clock. */
|
|
204
|
+
sent: z.array(sentItem).optional(),
|
|
205
|
+
/** None of these was sent; `{}` is anything at all. */
|
|
206
|
+
not_sent: z.array(sentItem).optional(),
|
|
127
207
|
})
|
|
128
208
|
.default({}),
|
|
129
209
|
});
|
|
@@ -149,6 +229,10 @@ export function mimeOf(path) {
|
|
|
149
229
|
const types = { jpg: "image/jpeg", jpeg: "image/jpeg", png: "image/png", webp: "image/webp", gif: "image/gif", pdf: "application/pdf", ogg: "audio/ogg", opus: "audio/ogg", mp3: "audio/mpeg", m4a: "audio/mp4", wav: "audio/wav" };
|
|
150
230
|
return types[extname(path).slice(1).toLowerCase()] ?? "application/octet-stream";
|
|
151
231
|
}
|
|
232
|
+
/** The business starts something in it (an event, a nudge, time passing), or it checks what was sent: a business run. */
|
|
233
|
+
export function isBusiness(scenario) {
|
|
234
|
+
return scenario.opening.some((s) => s.event || s.nudge || s.wait !== undefined || s.at !== undefined) || !!scenario.expect.sent || !!scenario.expect.not_sent || !!scenario.tools;
|
|
235
|
+
}
|
|
152
236
|
/** Parse and check a scenario. The error has one line per problem: `file:line: field: what is wrong`. */
|
|
153
237
|
export function parseScenario(file) {
|
|
154
238
|
return parseFile(scenarioSchema, file);
|
package/docs/agents.md
CHANGED
|
@@ -15,16 +15,24 @@ package.
|
|
|
15
15
|
```sh
|
|
16
16
|
npx @flow-engineer/flow init --display-name "FlatX agent" # the starter in this (empty) folder; ask the owner what to call it first
|
|
17
17
|
npm install
|
|
18
|
-
npx @flow-engineer/flow link --json
|
|
19
|
-
npm start # keep it running
|
|
18
|
+
npx @flow-engineer/flow link --channel telegram --json # or whatsapp, imessage: signs in and sets up if needed (the keys, Flow's trial model key among them), then prints the phone link
|
|
19
|
+
npm start # after link: keep it running; the playground link it prints, and the phone link reaches it
|
|
20
20
|
npx @flow-engineer/flow status --json # any time: what the folder has and lacks
|
|
21
21
|
printf 'hi\nA latte please\n' | npx @flow-engineer/flow chat # your own check: talk to it locally, one message per line
|
|
22
22
|
```
|
|
23
23
|
|
|
24
24
|
Before `init` (or `flow new <folder>`), ask the owner what people should call their agent, such as
|
|
25
25
|
"FlatX agent", and pass it as `--display-name`. It is kept as `displayName` in `package.json`, and
|
|
26
|
-
the message their phone sends to reach the agent is `Hi FlatX agent (ref <code>)
|
|
27
|
-
|
|
26
|
+
the message their phone sends to reach the agent is `Hi FlatX agent (ref <code>)`. The name takes the
|
|
27
|
+
starter's (Pip) place in the agent's prompt, `agent/AGENTS.md`, and the playground and MCP server
|
|
28
|
+
read it from `package.json`. To rename an agent made already, `link --display-name "<name>"` (or
|
|
29
|
+
`setup --display-name`) changes both; restart `npm start` after it. Without the flag, `init` asks in
|
|
30
|
+
a terminal and otherwise uses the folder's name in words.
|
|
31
|
+
|
|
32
|
+
`npm start` listens on `PORT`, else 8788: with 8788 taken and no `PORT` set it takes the next free
|
|
33
|
+
port and says so, and the playground link it prints has the port it uses. With `PORT` set to a port
|
|
34
|
+
that is taken it prints one line, `Port 8788 is in use. Run: PORT=8798 npm start`, and exits 1.
|
|
35
|
+
`npm run mcp` does the same with `MCP_PORT` (else 8790).
|
|
28
36
|
|
|
29
37
|
## How the owner tests it
|
|
30
38
|
|
|
@@ -64,7 +72,8 @@ before you hand it back; run them yourself, and do not offer them to the owner a
|
|
|
64
72
|
- `link` does the rest when the folder is not set up (no `FLOW_FRAMEWORK_JOIN_CODE` or
|
|
65
73
|
`FLOW_MESSAGING_KEY` in `.env`, or no sender for the WhatsApp or iMessage link asked for): it
|
|
66
74
|
runs `login` (opens the owner's browser itself and prints the link and code at once), then
|
|
67
|
-
`setup` (writes the keys and each open channel's sender into `.env
|
|
75
|
+
`setup` (writes the keys and each open channel's sender into `.env`, and the chosen channel's
|
|
76
|
+
page as `FLOW_FRAMEWORK_TRY_URL`), then prints the link and that channel's free-trial line only.
|
|
68
77
|
`--channel telegram|whatsapp|imessage` picks the channel (Telegram by default). Run it in the
|
|
69
78
|
agent's folder: `setup` and `link` refuse a folder with no `package.json` (`not_an_agent_folder`).
|
|
70
79
|
- A channel that is not open yet is said once ("iMessage isn't open yet; here are your Telegram
|
|
@@ -75,9 +84,13 @@ before you hand it back; run them yourself, and do not offer them to the owner a
|
|
|
75
84
|
24 hours of their last message); it cannot message them first. `link --json` says so
|
|
76
85
|
(`reply_only: true`, and in `say_to_person`): tell the owner.
|
|
77
86
|
- The starter's `npm start` is on Telegram, plus WhatsApp and iMessage when `.env` has their senders
|
|
78
|
-
(`FLOW_MESSAGING_WHATSAPP_SENDER`, `FLOW_MESSAGING_IMESSAGE_SENDER`), and reads `.env` once.
|
|
79
|
-
`
|
|
80
|
-
it
|
|
87
|
+
(`FLOW_MESSAGING_WHATSAPP_SENDER`, `FLOW_MESSAGING_IMESSAGE_SENDER`), and reads `.env` once.
|
|
88
|
+
`npm start` marks itself running in `.flow/running.json`, so `link` ends with the step that fits:
|
|
89
|
+
`Next: npm start, then send the link above` when it is not running; when it is, `Next: restart npm
|
|
90
|
+
start (it runs at <url> and lacks iMessage), then send the link above` if `link` just wrote `.env`
|
|
91
|
+
or renamed it, else `Next: send the link above (npm start is running at <url>)` (`link --json`:
|
|
92
|
+
`running`, `false` or `{"url"}`, and `next`). `setup` alone says "If npm start is running, restart it to add
|
|
93
|
+
iMessage."
|
|
81
94
|
- Give the owner `say_to_person` from `link --json`, and the sign-in link and code from the
|
|
82
95
|
`pending` line while you wait.
|
|
83
96
|
- `npm start` connects to Flow Messaging by an outbound event stream. Messages sent while it was
|
|
@@ -108,7 +121,8 @@ Every error is `{"error":{"code","message","next"}}` on stdout with `--json`, an
|
|
|
108
121
|
`next` is the exact command or URL to run; when it came from flow.engineer it is the server's.
|
|
109
122
|
A run gives one `next`: `setup --json`'s is a list in order (a model key first when there is
|
|
110
123
|
none, then `npm start` for the playground, then `link` for the phone); inside `link` setup's line
|
|
111
|
-
has none, and
|
|
124
|
+
has none, and `link`'s `next` is one string (a model key first when there is none, then starting or
|
|
125
|
+
restarting `npm start`); an error's `next` replaces it.
|
|
112
126
|
Run it, or give it to the owner when it needs them (a browser approval, a page to open). A
|
|
113
127
|
`slow_down` error also has `retry_after` (seconds, or `null` when the server gave none). A
|
|
114
128
|
trial limit's `next` is a sentence, not a command: it is printed on its own line as it came; pass
|
|
@@ -140,6 +154,14 @@ reach (`iMessage: 1 of 2 people`; `trial.imessage.contacts` `{limit, used}` in `
|
|
|
140
154
|
with `FLOW_FRAMEWORK_LOGIN_URL` naming another server, commands stop with `signed_in_elsewhere`.
|
|
141
155
|
- CI never signs in: `npx @flow-engineer/flow setup --ci` says how to give it the test cloud key.
|
|
142
156
|
|
|
157
|
+
## Messages the business starts
|
|
158
|
+
|
|
159
|
+
A nudge, a scheduled message or any turn the person did not start goes with link previews off
|
|
160
|
+
(`preview: false` on the message; Flow Messaging's WhatsApp `preview_url: false`): on WhatsApp a
|
|
161
|
+
preview card's title becomes the phone's notification instead of the message's text. For a call to
|
|
162
|
+
action use `link` (`agent.nudge({ to, text, link: { label, url } })`, or the agent's send with
|
|
163
|
+
`link`), not a URL in the text.
|
|
164
|
+
|
|
143
165
|
## Shared test numbers and privacy
|
|
144
166
|
|
|
145
167
|
The free trial runs on Flow's shared Telegram bot, WhatsApp number and iMessage line. Sending an
|