@flow-engineer/flow 0.0.1 → 0.2.1
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 +202 -0
- package/README.md +611 -0
- package/coding-agents/skill.md +353 -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 +40 -0
- package/dist/flow-messaging/src/client.js +142 -0
- package/dist/flow-messaging/src/index.d.ts +72 -0
- package/dist/flow-messaging/src/index.js +605 -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 +22 -0
- package/dist/src/names.js +43 -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/account.d.ts +4 -0
- package/dist/testing/src/account.js +580 -0
- package/dist/testing/src/chat.d.ts +3 -0
- package/dist/testing/src/chat.js +117 -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 +592 -0
- package/dist/testing/src/client.d.ts +117 -0
- package/dist/testing/src/client.js +956 -0
- package/dist/testing/src/flow-login.d.ts +143 -0
- package/dist/testing/src/flow-login.js +249 -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 +13 -0
- package/dist/testing/src/new.js +147 -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/docs/agents.md +85 -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 +43 -0
- package/examples/cafe/src/channels.ts +20 -0
- package/examples/cafe/src/main.ts +30 -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 +191 -0
- package/package.json +63 -8
- 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,353 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: flow-framework
|
|
3
|
+
description: Build or change a chat agent on Flow Framework (WhatsApp, SMS, iMessage, Telegram, web). Load when creating a Flow Framework agent, editing its prompt or skills, wiring it into the playground, or when code imports @flow-engineer/flow (formerly chinwag). Also load the flow-framework-tools skill (tools-skill.md) before writing tools and flow-framework-tests (test-skill.md) before writing tests.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Building an agent on Flow Framework
|
|
7
|
+
|
|
8
|
+
Flow Framework runs chat agents that feel like a person texting back: it folds double texts into one
|
|
9
|
+
reply, acknowledges people fast, runs tools in parallel and waits for them, moving one to the
|
|
10
|
+
background only when the person writes again or it passes its limit.
|
|
11
|
+
You write the agent's prompt, skills and tools; Flow Framework handles the conversation.
|
|
12
|
+
|
|
13
|
+
Two more skills go with this one: `flow-framework-tools` (`tools-skill.md`) before writing or changing a
|
|
14
|
+
tool, and `flow-framework-tests` (`test-skill.md`) before writing or changing a test.
|
|
15
|
+
|
|
16
|
+
## Start a new agent
|
|
17
|
+
|
|
18
|
+
`npx @flow-engineer/flow new <name>` (lowercase letters, digits and dashes) makes `./<name>/` from the café
|
|
19
|
+
example: a complete agent with tools and tests that depends on the `@flow-engineer/flow` package and imports
|
|
20
|
+
`@flow-engineer/flow` and `@flow-engineer/flow/simulators`, with these skills in `.claude/skills/`. Run `npm install` there.
|
|
21
|
+
Build in it, changing Pip's prompt, skills, tools and tests into yours. The owner copies
|
|
22
|
+
`.env.example` to `.env` and puts one model key in it (never read or print it). Flow Framework does not
|
|
23
|
+
issue that key; it is made at the provider: `OPENROUTER_API_KEY` at https://openrouter.ai/keys,
|
|
24
|
+
`OPENAI_API_KEY` at https://platform.openai.com/api-keys, `ANTHROPIC_API_KEY` at
|
|
25
|
+
https://console.anthropic.com/settings/keys, or `GEMINI_API_KEY` at https://aistudio.google.com/apikey.
|
|
26
|
+
The first one with a value is used, and `CAFE_MODEL` sets the café's model id. Then `npm start`
|
|
27
|
+
opens the playground; `npm run typecheck` checks the types.
|
|
28
|
+
|
|
29
|
+
Inside a clone of Flow Framework itself, `npm run new -- <name>` copies the café to `agents/<name>/`
|
|
30
|
+
instead (git-ignored, so it never collides with a pull). Its imports of `../../../src/` and
|
|
31
|
+
`../../../simulators/src/` stay as they are, so changes to Flow Framework show at once; the key goes in
|
|
32
|
+
`agents/<name>/.env`. The snippets below import `@flow-engineer/flow` and `@flow-engineer/flow/simulators`; in a clone's
|
|
33
|
+
agent, keep its relative imports.
|
|
34
|
+
|
|
35
|
+
## Layout
|
|
36
|
+
|
|
37
|
+
```
|
|
38
|
+
my-agent/
|
|
39
|
+
agent/AGENTS.md the agent's prompt (the model reads it every turn)
|
|
40
|
+
agent/skills/<name>/SKILL.md skills the agent loads when it needs them
|
|
41
|
+
src/tools.ts the agent's tools (see tools-skill.md)
|
|
42
|
+
src/agent.ts buildAgent(): the one place the agent is built
|
|
43
|
+
src/main.ts runs it in the playground
|
|
44
|
+
tests/tools/*.yaml how each tool behaves in tests (see tools-skill.md)
|
|
45
|
+
tests/scenarios/*.yaml the tests (see test-skill.md)
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
`agent/AGENTS.md` is the agent's prompt, not instructions for coding agents. Keep it short: what
|
|
49
|
+
the agent is for, what it may and may not do. Don't explain messaging in it: Flow Framework adds how to
|
|
50
|
+
talk to the person (send_<channel>, react, acknowledging first, channel formatting) on its own.
|
|
51
|
+
|
|
52
|
+
A skill's `SKILL.md` starts with `name` and `description`; the agent sees every description and
|
|
53
|
+
loads the full skill only when it decides it needs it. Put rules for one kind of request in a
|
|
54
|
+
skill, not in AGENTS.md.
|
|
55
|
+
|
|
56
|
+
## buildAgent
|
|
57
|
+
|
|
58
|
+
Build the agent in one function that the playground and the tests both call, so they never
|
|
59
|
+
drift apart. Tests pass their own model, events and simulated tools:
|
|
60
|
+
|
|
61
|
+
```ts
|
|
62
|
+
import { fileURLToPath } from "node:url";
|
|
63
|
+
import { createAgent, openAICompatible, type EventSink, type Model, type Tool } from "@flow-engineer/flow";
|
|
64
|
+
import { tools as myTools } from "./tools.ts";
|
|
65
|
+
|
|
66
|
+
export const tools = myTools;
|
|
67
|
+
|
|
68
|
+
export function buildAgent(options: { model?: Model; events?: EventSink[]; tools?: Tool[] } = {}) {
|
|
69
|
+
return createAgent({
|
|
70
|
+
dir: fileURLToPath(new URL("../agent", import.meta.url)),
|
|
71
|
+
model: options.model ?? openAICompatible({ model: "anthropic/claude-haiku-5.5" }),
|
|
72
|
+
tools: options.tools ?? myTools,
|
|
73
|
+
events: options.events ?? [],
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Other `createAgent` options: `files` replaces `dir` on hosts with no file system (the folder's
|
|
79
|
+
text by path: `{ "AGENTS.md": ..., "skills/<name>/SKILL.md": ... }`); `profile({ conversationId, person, channel })` is what the business knows about
|
|
80
|
+
the person, offered as the built-in tool `get_user_profile` and run only when the agent calls it (put
|
|
81
|
+
lookups here, not in `context`); `context(conversationId)` adds small facts to the system prompt at
|
|
82
|
+
every turn, before every reply, so it must not call APIs; Flow Framework already tells the agent the time; `intercept(conversationId, text, message)` answers a
|
|
83
|
+
message without the model (return the reply, `""` for silence, or `null` to let the agent answer;
|
|
84
|
+
the message and the reply are kept in the history, so the next turn knows, and in the transcript,
|
|
85
|
+
and are `message.in`/`message.out` events marked `answered: true`);
|
|
86
|
+
`deadlineMs` (default 60,000) is the longest a reply waits for a tool before it moves to the
|
|
87
|
+
background (set a tool's own `deadlineMs` for a known-slow one); `partialAfterMs` (default 5,000)
|
|
88
|
+
is how long a finished tool's result waits for the others of its response; `shareUpdates` turns live status lines on web and app UIs off, or replaces their
|
|
89
|
+
guidance with a string.
|
|
90
|
+
|
|
91
|
+
Models are reached through any OpenAI-compatible API. `openAICompatible()` with no key reads the
|
|
92
|
+
first of `OPENROUTER_API_KEY`, `OPENAI_API_KEY`, `ANTHROPIC_API_KEY` and `GEMINI_API_KEY` that has a
|
|
93
|
+
value, and uses that provider's default model (`MODEL` overrides it). With none it stops at once and
|
|
94
|
+
names them. Pass `apiKey` and `baseURL` for anywhere else (`baseURL` alone for a local server that needs no key).
|
|
95
|
+
|
|
96
|
+
Which model: recommend Claude Haiku 5.5 (`anthropic/claude-haiku-5.5`, the default above) or OpenAI
|
|
97
|
+
GPT-6.1 Sol (`openai/gpt-6.1-sol`, about 3x the cost, the most exact). If the owner asks for Luna
|
|
98
|
+
(`~openai/gpt-luna-latest`, `openai/gpt-6-luna`), advise against it before using it: with tools
|
|
99
|
+
that run in the background it repeats tool calls, sends extra "on it" messages and answers twice,
|
|
100
|
+
where Haiku and Sol send one acknowledgement and one answer (measured on Flow Framework's own checks, 12 Oct
|
|
101
|
+
2026). Use it only if they still want it.
|
|
102
|
+
|
|
103
|
+
## Channels, people and the database
|
|
104
|
+
|
|
105
|
+
In production, give the agent its channels once and hand it each incoming message. Each channel
|
|
106
|
+
is a definition in its own file; Flow Framework knows the platform (WhatsApp's limits, SMS segments), your
|
|
107
|
+
provider only says how to send:
|
|
108
|
+
|
|
109
|
+
```ts
|
|
110
|
+
// src/channels/whatsapp.ts: one sendMessage, plus whatever else your provider can do
|
|
111
|
+
export const wa = whatsapp({
|
|
112
|
+
sendMessage: (to, m) => api.send(to, m), // m: { text, replyTo?, buttons?, list?, link?, image? }
|
|
113
|
+
react: (to, id, emoji) => api.react(to, id, emoji),
|
|
114
|
+
// no typing() or markRead(): those features are off, and the agent is never offered them
|
|
115
|
+
}, { disable: ["lists"] });
|
|
116
|
+
|
|
117
|
+
// src/channels/app.ts: your own web or mobile UI, with params of your own
|
|
118
|
+
export const app = customUI("app", {
|
|
119
|
+
params: { app_link: z.string().describe("A link a tool result gave you; the app shows it as a button") },
|
|
120
|
+
instructions: "Only fill app_link with a URL from a tool result in this conversation; never make one up.",
|
|
121
|
+
sendMessage: (to, m) => myApp.push(to, m), // m.text, m.app_link
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
createAgent({ ..., channels: [wa, sms(twilioLike), app], database: sqlite("data/chinwag.db"),
|
|
125
|
+
resolve: ({ channel, address }) => members.idFor(channel, address), timeZone: "America/New_York" });
|
|
126
|
+
// A person's time zone is never guessed from timeZone (the business's): set it when you know it,
|
|
127
|
+
// agent.setTimeZone({ to, timeZone }) or peopleHandler over HTTP; else the agent asks them.
|
|
128
|
+
await agent.resume(); // a durable database: always before the first message
|
|
129
|
+
await agent.message({ channel: "whatsapp", from: "+15550100100", message: "hi" });
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
- Several processes or machines need Postgres, not a SQLite file: `database: postgres(pool)`
|
|
133
|
+
with the app's own node-postgres `Pool` (Flow Framework adds no Postgres dependency). An agent that must
|
|
134
|
+
not hold database credentials gets `store` instead of `database`: any object with Store's
|
|
135
|
+
methods (`StoreApi`), such as a client that forwards each call to a service running `new
|
|
136
|
+
Store(postgres(pool), agentId)`.
|
|
137
|
+
- Every send tool can carry a `link` ({ label, url }, https): a URL button where the channel has
|
|
138
|
+
one (`linkButton`, WhatsApp's cta_url), else Flow Framework puts the URL on a line after the text. Channels
|
|
139
|
+
with `images` also offer an `image` ({ url }, https), the text its caption; elsewhere the text
|
|
140
|
+
goes alone. WhatsApp's rules hold everywhere: a link never with buttons or a list, an image never
|
|
141
|
+
with a list. A provider without them says so (`features: { links: false, images: false }`) and
|
|
142
|
+
never gets them; the URLs come from your tools' results, like app_link's.
|
|
143
|
+
- The simulators and the test cloud play an agent's own channels with these definitions (the web
|
|
144
|
+
simulator plays a customUI), so the model sees the production tools; they show params such as
|
|
145
|
+
app_link under the message instead of calling your sendMessage.
|
|
146
|
+
- `instructions` (on `customUI`, and in `whatsapp()`'s and `sms()`'s options) is your guidance for
|
|
147
|
+
the agent on that channel, in its prompt only while the person uses it. Data a param carries,
|
|
148
|
+
such as a link, comes from your tools' results: a tool that has a screen returns its link
|
|
149
|
+
(`{ ..., app_link }`), and the agent only passes it on.
|
|
150
|
+
- One person is one conversation across every channel they use. Channels are joined only by what
|
|
151
|
+
each verified: your `resolve` key, else the same phone number on channels marked `phone`. Never
|
|
152
|
+
join people any other way. When the SMS provider gives national numbers, set
|
|
153
|
+
`defaultCountryCode` ("+91") so they match the full number WhatsApp gives.
|
|
154
|
+
- Tools key a person's data by `context.person`, their stable id: `key:<resolve's value>`, else
|
|
155
|
+
`phone:<E.164>`, else `<channel>:<address>`. `context.key` is `resolve`'s value as returned, or
|
|
156
|
+
null without one: look them up in the backend by that, never by `context.person`.
|
|
157
|
+
- With several channels the agent gets a send tool per channel (`send_sms`, `send_web`) and
|
|
158
|
+
answers each message where it came. Don't add your own.
|
|
159
|
+
- Run exactly one copy of the agent per database (one instance, kept warm, CPU always on): a
|
|
160
|
+
conversation lives in one process, so two copies would run overlapping turns for it.
|
|
161
|
+
- After a restart, `resume()` carries on: cut-off turns run again, background tools that were
|
|
162
|
+
running are reported as interrupted (never rerun).
|
|
163
|
+
- `agent.receive(conversationId, message, deliver)` still works for one channel at a time (the
|
|
164
|
+
test cloud uses it).
|
|
165
|
+
- Photos, voice notes and documents: hand them in on the message, `message: { text: caption, attachments: [{ kind: "image" | "audio" | "document", mime, name?, data: () => provider.download(id) }] }`.
|
|
166
|
+
`data` is a loader your provider fetches with its own credentials; never put a media URL where
|
|
167
|
+
the model sees it. Photos (JPEG, PNG, WebP, GIF, up to 5 MB) and PDFs (up to 10 MB) go to the
|
|
168
|
+
model when it reads them (`openAICompatible({ images, pdfs })`: images default on, PDFs on with
|
|
169
|
+
OpenRouter; set `images: false` for a text-only model). Voice notes need
|
|
170
|
+
`createAgent({ transcribe: async ({ mime, data }) => text })` with the speech-to-text the owner
|
|
171
|
+
picks; Flow Framework has none. Anything the agent can't read reaches it named, with why, so it asks
|
|
172
|
+
for text. The latest 3 photos and PDFs stay shown; their bytes are never stored.
|
|
173
|
+
|
|
174
|
+
Built in, so don't build them as tools: what the business knows about the person (`profile`, as
|
|
175
|
+
`get_user_profile`), scheduled messages (`schedule_message`, `list_scheduled`,
|
|
176
|
+
`cancel_scheduled`, in the person's local time), and what a person asked not to be messaged about
|
|
177
|
+
(`add_no_contact`, `remove_no_contact`), quiet hours they set (`set_quiet_hours`) and pauses
|
|
178
|
+
(`pause_messages`). STOP, START and HELP are built in on `sms()` and
|
|
179
|
+
`whatsapp()`: don't build them in `intercept`. STOP stops every message the business starts, with
|
|
180
|
+
no model call, until START; set the replies with `keywords: { replies: { stop, start, help } }`.
|
|
181
|
+
`intercept` sees them first, with `message.keyword` `"stop"`, `"start"` or `"help"` (else `null`)
|
|
182
|
+
and `message.channel` (the channel's name in `channels`, on every message): an agent with its own
|
|
183
|
+
opt-out flag sets it there
|
|
184
|
+
(`if (message.keyword === "stop") optedOut.add(conversationId)`; only on SMS:
|
|
185
|
+
`message.channel === "sms" && ...`). Flow Framework keeps or lifts the STOP
|
|
186
|
+
whatever the hook returns or throws; a reply it returns goes instead of Flow Framework's, never both.
|
|
187
|
+
SMS takes only STOP by default; the words carriers or regulators expect differ by country, so
|
|
188
|
+
check the market's rules (`keywords: US_CARRIER_KEYWORDS` adds STOPALL, UNSUBSCRIBE, CANCEL, END
|
|
189
|
+
and QUIT, as some US carrier rules expect; `keywords: { stop: [...] }` sets your own).
|
|
190
|
+
|
|
191
|
+
Messages the business starts are nudges: `agent.nudge({ to: { channel, address }, text })` sends
|
|
192
|
+
as written; `prompt` instead lets the agent write it (or decide not to); `template` sends a
|
|
193
|
+
WhatsApp template you already set up. Pass `idempotencyKey` when your system may retry, and `at`
|
|
194
|
+
to send later. Prefer `prompt`; use `text` only when the wording must be exact. Outside WhatsApp's
|
|
195
|
+
24-hour window only templates go out: declare a `fallbackTemplate` ("You have a new message. Reply to
|
|
196
|
+
see it.", no values) so a nudge is held until they reply instead of refused (`outside_window`); the
|
|
197
|
+
agent may send a fitting template itself (`send_template`), and `template.missing` events say which
|
|
198
|
+
messages want one. Held nudges (quiet hours, a closed window) settle as `held`, then `sent` or `expired`.
|
|
199
|
+
|
|
200
|
+
When the owner's backend is a separate service, mount `nudgeHandler(agent, { token })` (from
|
|
201
|
+
`@flow-engineer/flow`; a token of at least 32 characters, from a secret; `base` is `/nudges` by default) in the
|
|
202
|
+
agent's server rather than writing a route: `POST /nudges` takes the same fields with
|
|
203
|
+
`idempotency_key` and `at` in ms since the epoch, and answers `202 { id, status }` once recorded,
|
|
204
|
+
before any send or turn (`queued`, or `refused`/`dropped`/`scheduled` decided then: still a 202);
|
|
205
|
+
`GET /nudges/<id>` reads its state. Both need `Authorization: Bearer <token>`. Contract:
|
|
206
|
+
`spec/nudges.openapi.yaml`.
|
|
207
|
+
|
|
208
|
+
Templates are WhatsApp only. Declare the ones the owner set up with Meta beside `testPeople`, so
|
|
209
|
+
Flow Framework checks each send against them and the playground shows them as WhatsApp does:
|
|
210
|
+
|
|
211
|
+
```ts
|
|
212
|
+
createAgent({
|
|
213
|
+
...,
|
|
214
|
+
templates: [{
|
|
215
|
+
name: "order_ready", language: "en", category: "utility", // or marketing, authentication
|
|
216
|
+
header: { kind: "text", text: "Order {{1}}" }, // or { kind: "image" | "video" | "document" }
|
|
217
|
+
body: "Hi {{1}}, your {{2}} is ready.",
|
|
218
|
+
footer: "Reply STOP to opt out",
|
|
219
|
+
buttons: [
|
|
220
|
+
{ type: "quick_reply", text: "On my way" },
|
|
221
|
+
{ type: "url", text: "Track", url: "https://example.com/o/{{1}}" },
|
|
222
|
+
{ type: "phone", text: "Call us", phone: "+15555550100" },
|
|
223
|
+
],
|
|
224
|
+
}],
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
await agent.nudge({ to, template: {
|
|
228
|
+
name: "order_ready",
|
|
229
|
+
header: { kind: "text", text: "1042" }, // media: { kind: "image", url } or { kind, fileId }
|
|
230
|
+
body: ["Maria", "latte"], // {{1}}, {{2}}, ... in order
|
|
231
|
+
buttons: [{ index: 0, value: "omw_1042" }, { index: 1, value: "1042" }], // a quick reply's payload, a URL's end
|
|
232
|
+
} });
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
A declared template sent with the wrong values (a body value too few or many, a missing header or
|
|
236
|
+
URL value) throws from `nudge` before anything is sent, in the playground and live alike. One not
|
|
237
|
+
declared goes as it is (the provider checks it) and the playground shows its name and values. A
|
|
238
|
+
quick-reply tap comes back as the person's message with `choice: { id: payload, title }`, like a
|
|
239
|
+
reply button, and the agent is told what the template said. An ordinary message has one kind of
|
|
240
|
+
button: up to 3 reply buttons OR one link button, never both. A template may mix quick replies and
|
|
241
|
+
URL buttons.
|
|
242
|
+
|
|
243
|
+
## Ask the owner when you set an agent up
|
|
244
|
+
|
|
245
|
+
Before the agent sends anything people did not ask for (nudges, follow-ups), ask the owner these
|
|
246
|
+
two, with these options, and build what they pick:
|
|
247
|
+
|
|
248
|
+
1. **Quiet hours** (what the business starts waits for them, in each person's own time):
|
|
249
|
+
"22:00–07:00 (recommended)", "other hours", or "none" (`quietHours: false`). For US marketing,
|
|
250
|
+
say that 8am–9pm the recipient's time is, as far as we know, the legal window, and check theirs.
|
|
251
|
+
2. **Where people's time zones come from**, since quiet hours apply only where it is known:
|
|
252
|
+
"our database" (sync it with `agent.setTimeZone` or `peopleHandler`), or "we don't know" (the
|
|
253
|
+
agent asks people when it needs it; until then nothing is held for quiet hours).
|
|
254
|
+
|
|
255
|
+
## The playground
|
|
256
|
+
|
|
257
|
+
Every agent ships a test playground: people test it as real users before its numbers are live.
|
|
258
|
+
Declare who may be played in `createAgent({ testPeople: [{ name: "Maria", phone: "+15555550101" }] })`;
|
|
259
|
+
no one else can be. A test person is a real person to the agent: the same identity, `resolve`,
|
|
260
|
+
`intercept`, history and schedules as when that number writes through a provider (the playground
|
|
261
|
+
is just another phone; they may be test members in your real backend). On phone channels their
|
|
262
|
+
address is the number; on others `test:<number>`, joined to the same person. One person is one
|
|
263
|
+
conversation across every channel: switch channels and the chat resumes. A listed number may also
|
|
264
|
+
write from a real provider once a channel is live; replies, scheduled messages and nudges go back
|
|
265
|
+
wherever that person last wrote from on that channel.
|
|
266
|
+
|
|
267
|
+
`src/main.ts` serves it with the simulators (SMS, WhatsApp, and the web simulator for a custom UI),
|
|
268
|
+
each with that platform's real rules, on the agent's own `channels` (declare them, with a provider
|
|
269
|
+
that is not connected yet if the numbers are not ready: test people never reach it):
|
|
270
|
+
|
|
271
|
+
```ts
|
|
272
|
+
import { simulators } from "@flow-engineer/flow/simulators";
|
|
273
|
+
import { buildAgent } from "./agent.ts";
|
|
274
|
+
|
|
275
|
+
const play = simulators({ title: "My agent" });
|
|
276
|
+
const agent = buildAgent({ events: [play.events] });
|
|
277
|
+
await play.listen(agent, 8788); // 127.0.0.1 only, its own page only; no token needed there
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
Open the printed link, pick a test person and a channel, and chat; `/person` shows every channel
|
|
281
|
+
side by side. The page shows the person's conversation from the agent's store, so it survives
|
|
282
|
+
restarts (with a durable `database`), and Clear history forgets the person entirely
|
|
283
|
+
(`agent.forgetPerson(conversationId)`: history, schedules, everything). The side panel shows how
|
|
284
|
+
fast each message was acknowledged and a trace of every model call and tool. `cards` says how a
|
|
285
|
+
custom UI shows a link in its params: `{ match: "/video/", label: "Start video call", kind: "call" }`;
|
|
286
|
+
any other link is "Open in app".
|
|
287
|
+
|
|
288
|
+
The Person tab also moves the agent's clock when you give it one (`const clock = playgroundClock()`,
|
|
289
|
+
`simulators({ clock })` and `createAgent({ now: clock.now })`), lists what is scheduled or held for
|
|
290
|
+
the person, sets their time zone and quiet hours, and sends nudges with every field the API takes.
|
|
291
|
+
|
|
292
|
+
To demo what your backend does (an order ships), send the nudge it would send from the Person tab.
|
|
293
|
+
|
|
294
|
+
On a deployed agent, mount it in your own server with a token of at least 32 characters, from a
|
|
295
|
+
secret: `const playground = play.handler(agent, { token: process.env.PLAYGROUND_TOKEN!, base: "/playground" })`,
|
|
296
|
+
and hand it the requests under `/playground` (`if (url.pathname.startsWith("/playground")) return playground(req, res)`).
|
|
297
|
+
Open `/playground/?token=...` once (it becomes an HttpOnly cookie and leaves the URL) or call the API
|
|
298
|
+
with `Authorization: Bearer <token>`. Wrong tokens are limited per address, and every simulated
|
|
299
|
+
message is logged with the person, channel, time and address.
|
|
300
|
+
|
|
301
|
+
## Put it on a phone
|
|
302
|
+
|
|
303
|
+
To chat with the agent from the owner's phone, with no tunnel and no key made by hand, run these
|
|
304
|
+
from the agent's folder (`npx @flow-engineer/flow init` makes a starter in an empty one). Only
|
|
305
|
+
`chat` reads stdin; nothing else asks for input or needs a terminal.
|
|
306
|
+
|
|
307
|
+
```sh
|
|
308
|
+
printf 'hi\nA latte please\n' | npx @flow-engineer/flow chat # locally, no account: one message per line, replies printed
|
|
309
|
+
npx @flow-engineer/flow link --json # signs in and runs setup when needed, then the link; give its say_to_person to the owner
|
|
310
|
+
npx @flow-engineer/flow test --synthetic # the tests, on the key setup wrote
|
|
311
|
+
npm start # the agent; the owner texts it while it runs
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
`link` runs `login` first when not signed in: it opens the owner's browser itself and prints the
|
|
315
|
+
link and code at once (with `--json` the first line is `{"status":"pending","verification_uri":...,
|
|
316
|
+
"user_code":...,"next":...}`), waits at most 120 s (`--timeout <s>`), then exits 4 with the code
|
|
317
|
+
kept: give the owner the link and code, and run `link` again (it polls the same code). Then it runs
|
|
318
|
+
`setup`, which writes the test cloud, traces and Flow Messaging keys into `.env`, prints names and
|
|
319
|
+
last 4 characters only, keeps values already there (`--replace` takes the new ones), and is safe to
|
|
320
|
+
run again. `--channel telegram|whatsapp|imessage` picks the channel; a channel flow.engineer cannot
|
|
321
|
+
open yet exits 3 with its `next`. `login`, `setup` and `link` also run on their own.
|
|
322
|
+
|
|
323
|
+
In `src/main.ts`, put `fm.telegram()` from `@flow-engineer/flow/flow-messaging` in the agent's
|
|
324
|
+
channels and call `fm.start(agent, { cursorFile: ".flow/flow-messaging-cursor" })`: with no webhook
|
|
325
|
+
secret it opens Flow Messaging's event stream, an outbound connection, so the laptop needs no public
|
|
326
|
+
URL, and the cursor file lets messages sent while it was stopped arrive when it starts again. The
|
|
327
|
+
starter does this when `.env` has `FLOW_MESSAGING_KEY`. Each error is
|
|
328
|
+
`{"error":{"code","message","next"}}`: run its `next`. Exit codes: 0 done, 1 usage or not signed in,
|
|
329
|
+
2 network, 3 refused, 4 sign-in waiting. Never ask the owner to paste a key into the chat. Always
|
|
330
|
+
name the package (`npx @flow-engineer/flow ...`): `npx flow` on its own fetches another npm package.
|
|
331
|
+
CI never logs in: `setup --ci` says how to give it the cloud key.
|
|
332
|
+
|
|
333
|
+
## Send traces to a dashboard
|
|
334
|
+
|
|
335
|
+
Every turn can go to a traces endpoint as an OTLP/JSON trace, posted to `<url>/v1/traces`, so
|
|
336
|
+
playground and real chats show up in a dashboard and can be graded. No code: set these in the
|
|
337
|
+
agent's environment. The address is https://flow.engineer/observe unless `FLOW_FRAMEWORK_TRACES_URL`
|
|
338
|
+
names another; the trace key is made on that same site (never print it):
|
|
339
|
+
|
|
340
|
+
```sh
|
|
341
|
+
FLOW_FRAMEWORK_TRACES_KEY=...
|
|
342
|
+
FLOW_FRAMEWORK_TRACES_URL=https://flow.engineer/observe # the default; set it only to send them elsewhere
|
|
343
|
+
FLOW_FRAMEWORK_TRACES_CONTENT=1 # send what was written; without it, metadata only
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
Without `FLOW_FRAMEWORK_TRACES_CONTENT=1` the dashboard
|
|
347
|
+
gets ids, timing, model calls and tool names but no messages, so nothing to show or grade. Or in
|
|
348
|
+
code: `createAgent({ ..., traces: { url, key, content: true, version: "v3" } })`. Traces are sent in the
|
|
349
|
+
background after each turn and a failed send is dropped (logged once), so a dashboard outage never
|
|
350
|
+
touches a chat; call `await agent.flushTraces()` before the process exits. Tests run under
|
|
351
|
+
`node --test` never send from the environment. Under `flow test` the traces say they are a test's
|
|
352
|
+
(`chinwag.run_kind` is "test", else "production", and each turn names the test session as
|
|
353
|
+
`chinwag.test.session`), so the dashboard keeps them apart from real conversations.
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: flow-framework-tests
|
|
3
|
+
description: Write or change tests for a Flow Framework agent (tests/scenarios/*.yaml). Load when adding a test scenario, deciding what a Flow Framework agent should be checked on, or turning a bad conversation into a test.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Writing tests for a Flow Framework agent
|
|
7
|
+
|
|
8
|
+
Read the `flow-framework` skill (`skill.md`) first if you haven't.
|
|
9
|
+
|
|
10
|
+
A test is a scenario: a situation to put the agent in, played through a platform simulator by a
|
|
11
|
+
model acting as the person, with the agent's tools played by a model from `tests/tools/*.yaml`
|
|
12
|
+
(see the `flow-framework-tools` skill, `tools-skill.md`). At the end, what happened is checked against `expect`.
|
|
13
|
+
|
|
14
|
+
```yaml
|
|
15
|
+
id: latte-correction
|
|
16
|
+
title: Orders a latte, corrects the milk mid-thought, confirms
|
|
17
|
+
channels: [whatsapp, sms] # runs once on each, with a result for each
|
|
18
|
+
|
|
19
|
+
person:
|
|
20
|
+
persona: Sam, in a hurry on the way to work; types in short bursts, no capitals.
|
|
21
|
+
goal: Get a large oat latte for pickup, under the name Sam.
|
|
22
|
+
knows:
|
|
23
|
+
- Their name is Sam.
|
|
24
|
+
|
|
25
|
+
opening:
|
|
26
|
+
- say: "can i get a large latte"
|
|
27
|
+
- say: "oat milk actually"
|
|
28
|
+
after_ms: 1200
|
|
29
|
+
|
|
30
|
+
world: |
|
|
31
|
+
The menu is as in tests/tools/get_menu.yaml. Oat milk is in stock. The café is busy this
|
|
32
|
+
morning, so the point of sale is slow to take orders, but they go through.
|
|
33
|
+
|
|
34
|
+
max_turns: 8
|
|
35
|
+
|
|
36
|
+
expect:
|
|
37
|
+
outcome:
|
|
38
|
+
- Exactly one order is placed, for one large latte with oat milk, under the name Sam.
|
|
39
|
+
judge:
|
|
40
|
+
- The order read back to Sam uses oat milk, not whole milk.
|
|
41
|
+
- The agent asks Sam to confirm before placing the order.
|
|
42
|
+
- Sam is told the order number and when it will be ready.
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## The parts
|
|
46
|
+
|
|
47
|
+
- `channels`: one or more of sms, whatsapp, imessage, telegram and web; each is a run of its own.
|
|
48
|
+
The run uses that simulator's real rules (SMS has no buttons or reactions; WhatsApp has both).
|
|
49
|
+
- `person`: who the model playing the person is (`persona`), what they want (`goal`), and facts
|
|
50
|
+
they only give if asked (`knows`). It sees only what a person would see.
|
|
51
|
+
- `opening`: the first messages, sent exactly as written; `after_ms` sends one that long after
|
|
52
|
+
the previous one (a double text). Then the person plays on until `goal` is met or `max_turns`
|
|
53
|
+
(default 8). A run that uses every turn never passes.
|
|
54
|
+
A step may send a file: `- { say: "here is my receipt", attach: { kind: image, file: fixtures/receipt.jpg } }`
|
|
55
|
+
(`kind`: image, audio or document; `file` a path in `tests/`; `say` is its caption and may be
|
|
56
|
+
left out). The file is read on your machine and never uploaded; the person and the judge see it
|
|
57
|
+
by name only.
|
|
58
|
+
- `world`: everything true in this test, in plain words. It is the only place the situation is
|
|
59
|
+
set: stock, outages, slowness, the person's history. The tool simulator plays every tool from
|
|
60
|
+
it, choosing among the responses in `tests/tools/*.yaml`. Never write tool responses or
|
|
61
|
+
latencies in a scenario. An agent with a `profile` gets it played from here too
|
|
62
|
+
(`get_user_profile`): say who the person is ("Sam, a member since 2024, on the monthly plan").
|
|
63
|
+
- `expect`:
|
|
64
|
+
- `outcome`: what must have happened, checked against what the tools did ("exactly one
|
|
65
|
+
order is placed", "no order is placed").
|
|
66
|
+
- `judge`: what the conversation must show, one checkable sentence each; a judge model reads
|
|
67
|
+
the transcript and marks each pass or fail with a reason.
|
|
68
|
+
- `calls`: tools that must be called at least once (whatever the call returned); `never`: tools
|
|
69
|
+
that must not be called. Checked by code from the recorded tool calls, not by a model. Each
|
|
70
|
+
entry is a tool name, or `{ tool, args }`: the call matches when every field in `args` equals
|
|
71
|
+
the call's input (nested objects match in part; arrays and strings must be equal).
|
|
72
|
+
- `timezone` (default UTC) and `now`: the test's clock. `now` is when each run starts,
|
|
73
|
+
`YYYY-MM-DDTHH:MM[:SS]`, local to `timezone` unless it ends in `Z` or an offset (`-04:00`); left
|
|
74
|
+
out, it is the real time. Placeholders are filled from it, and the simulated tools and the judge
|
|
75
|
+
are told it; your agent's clock is not changed: the agent still reads the real time. So set `now` only when the agent takes
|
|
76
|
+
its dates from its tools or its context; otherwise leave it out and placeholders follow the real
|
|
77
|
+
date.
|
|
78
|
+
|
|
79
|
+
```yaml
|
|
80
|
+
timezone: America/New_York
|
|
81
|
+
now: 2026-10-16T09:00
|
|
82
|
+
world: Sam's class is on {{now + 6d @ 10:00}}. There is a free slot s-2 the day after.
|
|
83
|
+
expect:
|
|
84
|
+
calls:
|
|
85
|
+
- cancel_session
|
|
86
|
+
- tool: move_session
|
|
87
|
+
args: { slot_id: s-2 }
|
|
88
|
+
never:
|
|
89
|
+
- delete_account
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Dates in test files are placeholders for the test's clock, never written out (a written date goes
|
|
93
|
+
stale): `{{now}}`, then shifts `+` or `-` a number with `m`, `h`, `d` or `w`, then `@ HH:MM` for the
|
|
94
|
+
time of day, then `| iso` (the default, with its offset), `| date` (2026-10-16), `| time` (09:00) or
|
|
95
|
+
`| weekday` (Thursday): `{{now + 7d | date}}`, `{{now - 2h + 30m | time}}`. They work in `world`,
|
|
96
|
+
`person`, `opening`, `expect` and the tool files' `responses`; the cloud fills them on each run. Any other
|
|
97
|
+
`{{...}}` is plain text.
|
|
98
|
+
|
|
99
|
+
How fast the agent first replies to each message is measured on every run from timestamps, not
|
|
100
|
+
written in scenarios: the dashboard shows it for the whole run (average, p95, min and max).
|
|
101
|
+
|
|
102
|
+
## Choosing what to test
|
|
103
|
+
|
|
104
|
+
- One situation per scenario, named for it (`order-fails`, `latte-correction`).
|
|
105
|
+
- Test what would hurt a real person if it broke: wrong order, a claim the order was placed when
|
|
106
|
+
it wasn't, a missed escalation, a slow or missing acknowledgement.
|
|
107
|
+
- One fact per check. "Called X" or "never called Z" is a `calls` or `never` entry, not an
|
|
108
|
+
`outcome` or `judge` sentence: code checks it the same way every run.
|
|
109
|
+
- Write `judge` items as facts a reader can check from the transcript, not taste ("asks Sam to
|
|
110
|
+
confirm before placing the order", not "is helpful").
|
|
111
|
+
- Cover the unhappy paths the tool files list (busy, down, unknown item) as their own scenarios.
|
|
112
|
+
- When a real conversation went wrong, turn it into a scenario: its world is what was true then,
|
|
113
|
+
its opening is what the person sent.
|
|
114
|
+
|
|
115
|
+
## Running the tests
|
|
116
|
+
|
|
117
|
+
Tests run in the Flow Framework cloud: your agent stays on your machine with its own model and key and
|
|
118
|
+
connects out to the cloud, which plays the person and the tools and judges.
|
|
119
|
+
|
|
120
|
+
1. Have a test cloud: the hosted one at https://flow.engineer/observe, the default (see "Get a test
|
|
121
|
+
cloud" below), or another with `--cloud <url>` or `FLOW_FRAMEWORK_CLOUD`.
|
|
122
|
+
2. From the agent's folder (it must export `buildAgent` and `tools` from `src/agent.ts`), which reads
|
|
123
|
+
the agent's `.env` itself: `npx @flow-engineer/flow test --synthetic [--runs 3]` (`npm test -- --synthetic`
|
|
124
|
+
in an agent `flow new` made).
|
|
125
|
+
3. It first reads every test file as the cloud does; a mistake stops it with
|
|
126
|
+
`tests/scenarios/<file>.yaml:<line>: <field>: <what is wrong>`, one line each, and nothing is sent.
|
|
127
|
+
Then it prints PASS or FAIL per run, with its run id, every failed check and the judge's reason,
|
|
128
|
+
and a link to watch each conversation in the simulator. The cloud's dashboard, at its own
|
|
129
|
+
address, shows every session over time and each scenario's history.
|
|
130
|
+
4. `--json` prints one JSON report on stdout at the end, whatever the exit (everything else goes to
|
|
131
|
+
stderr): `outcome`, `exitCode`, `sessionId`, `agent`, `error`, `counts`, `costUsd` and per run
|
|
132
|
+
`runId`, `scenario`, `channel`, `run`, `verdict`, `error` (the cloud's code when it could not play
|
|
133
|
+
or judge the run, such as `judge_failed`; the run is then `errored` and its line reads `ERROR`,
|
|
134
|
+
whatever it ended as), `ended`, `turns`, `watch` and `failedChecks` (`kind`, `text` and the judge's
|
|
135
|
+
`reason`). Each session that opened also leaves
|
|
136
|
+
`.flow/last-session.json` in the agent's folder (its path is printed on every exit): `sessionId`,
|
|
137
|
+
`agent`, `dashboard`, `suiteHash`, `endedAt`, `ended` (`finished`, `stopped` or `interrupted`),
|
|
138
|
+
`only` (the scenarios it was limited to, else `null`) and per run `scenario` (file name without
|
|
139
|
+
`.yaml`), `channel`, `run`, `runId`, `verdict` (`passed`, `failed`, `errored`: the cloud could not
|
|
140
|
+
play or judge it, such as a failed tool simulator or person model, which is not a failure of your
|
|
141
|
+
agent, or `not_played`: the session ended first) and `failedChecks` (with reasons).
|
|
142
|
+
Read these instead of parsing the printed lines.
|
|
143
|
+
Exit status: 0 all passed; 1 a run failed, or nothing was sent (a wrong flag, a test file that does
|
|
144
|
+
not parse, no key); 2 the connection or the cloud failed, or runs errored and none failed; 3 the
|
|
145
|
+
cloud refused (key, busy, spend cap, capacity); 130 Ctrl-C. On 2 or 3 the agent is not at fault:
|
|
146
|
+
say what the cloud said (`error` in the report), do not change the agent for it.
|
|
147
|
+
5. `--only a,b` runs only those scenarios (file names without `.yaml`). `--failed` runs only the ones
|
|
148
|
+
that did not pass in the last session, on every channel each plays; it goes with `--runs`, never
|
|
149
|
+
with `--only`. It prints `Nothing failed in the last session (<id>).` and exits 0 when there is
|
|
150
|
+
nothing to run again, and exits 1 when there is no last session.
|
|
151
|
+
|
|
152
|
+
Work in this loop, and never finish on a partial run:
|
|
153
|
+
|
|
154
|
+
1. `npx @flow-engineer/flow test --synthetic`: the whole suite.
|
|
155
|
+
2. Fix what failed (the agent, or a test that asks for the wrong thing; say which).
|
|
156
|
+
3. `npx @flow-engineer/flow test --synthetic --failed`, again until every run in it passes (exit 0).
|
|
157
|
+
4. `npx @flow-engineer/flow test --synthetic`: the whole suite once more, since a fix can break another scenario.
|
|
158
|
+
|
|
159
|
+
### When a run fails
|
|
160
|
+
|
|
161
|
+
1. `npx @flow-engineer/flow test --synthetic --json > report.json`, and read the runs whose `verdict` is not
|
|
162
|
+
`passed` (`jq '.runs[] | select(.verdict != "passed")' report.json`).
|
|
163
|
+
2. `npx @flow-engineer/flow show last` (the first run that did not pass; it names the others) or
|
|
164
|
+
`npx @flow-engineer/flow show <runId>`: the checks with the judge's reasons, then the conversation in time
|
|
165
|
+
order with every tool call the simulator played, its input, output and what it changed.
|
|
166
|
+
`--json` gives the whole record.
|
|
167
|
+
3. Decide which is wrong, and say which:
|
|
168
|
+
- the agent: it said or did the wrong thing; fix its prompt, tools or code;
|
|
169
|
+
- the test: a check asks for the wrong thing, or the scenario cannot reach its goal; fix the YAML;
|
|
170
|
+
- the simulator: a tool output that could not happen, or a person who left the scenario; fix
|
|
171
|
+
`tests/tools/*.yaml` or the scenario's `world` and `person`.
|
|
172
|
+
4. `npx @flow-engineer/flow test --synthetic --failed`, then `--runs 3 --only <scenario>` on what you fixed, so
|
|
173
|
+
a pass is not luck.
|
|
174
|
+
5. The whole suite once more.
|
|
175
|
+
|
|
176
|
+
### Get a test cloud
|
|
177
|
+
|
|
178
|
+
The owner does these; never ask for the key in chat or print it:
|
|
179
|
+
|
|
180
|
+
1. Sign in at https://flow.engineer/observe (Google, GitHub or an email link).
|
|
181
|
+
2. Workspace → New agent.
|
|
182
|
+
3. On the agent's Developers page, "Make a CI key". It starts with `oci_` and is shown once.
|
|
183
|
+
4. Put it in the agent's `.env` as `FLOW_FRAMEWORK_CLOUD_KEY` (never on the command line).
|
|
184
|
+
5. `npx @flow-engineer/flow test --cloud https://flow.engineer/observe --synthetic` from the agent's folder (`--cloud` is that address by default).
|
|
185
|
+
|
|
186
|
+
Your agent runs on your machine with your own model key. The cloud's person simulator, tool simulator and judge are free up to $5 a day per workspace, with 2 sessions at once; test data only (`--synthetic`), and records are deleted after 30 days. `--synthetic` is the promise that the test files hold no real customer or member data;
|
|
187
|
+
the CLI refuses a hosted cloud without it and the key. `--environment staging` or `production` is
|
|
188
|
+
kept with the session.
|
|
189
|
+
|
|
190
|
+
The open goes to the front door with the key; the session then plays at the tunnel the cloud
|
|
191
|
+
answers, with the session's own token. `--agent-version` (else `GITHUB_SHA`) and `--ref` (else
|
|
192
|
+
GitHub's branch) are kept with the session. A CI step (GitHub Actions shown):
|
|
193
|
+
|
|
194
|
+
```yaml
|
|
195
|
+
- run: npx @flow-engineer/flow test --cloud https://flow.engineer/observe --synthetic --environment staging
|
|
196
|
+
env:
|
|
197
|
+
FLOW_FRAMEWORK_CLOUD_KEY: ${{ secrets.FLOW_FRAMEWORK_CLOUD_KEY }}
|
|
198
|
+
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
Only the files in your `tests/` folder run; the cloud keeps each file by its hash and compares
|
|
202
|
+
runs only while the tests are the same. Every run calls real models, so it costs a little.
|
|
203
|
+
|
|
204
|
+
## An agent on another framework, in any language
|
|
205
|
+
|
|
206
|
+
An agent not built on Flow Framework is tested through an HTTP endpoint: `npx @flow-engineer/flow test --agent-url <url>`.
|
|
207
|
+
To implement one:
|
|
208
|
+
|
|
209
|
+
1. Read the contract, `spec/agent-endpoint.openapi.yaml` (in the @flow-engineer/flow package or the repository),
|
|
210
|
+
with `spec/request.schema.json` and `spec/response.schema.json`. Implement exactly that: one
|
|
211
|
+
POST per message of the person's, answered in the same response with status 200 and
|
|
212
|
+
`{ "replies": [...] }` (`[]` for no reply, never 204). Keep history by `conversationId`, ignore
|
|
213
|
+
request fields you do not know, and check `authorization: Bearer` when `FLOW_FRAMEWORK_AGENT_TOKEN` is set.
|
|
214
|
+
2. Only send what the run's platform shows (`x-chinwag-platforms` in the spec): no buttons on SMS,
|
|
215
|
+
text within `maxLength`. Name the person's messages by the `messageId` they came with.
|
|
216
|
+
3. Start the endpoint and run `npx @flow-engineer/flow check-endpoint <url>` (`--token-env NAME` when it needs
|
|
217
|
+
a token). Fix every FAIL line; it must end "5 of 5 cases passed". Give the agent a mode with no
|
|
218
|
+
model call (the examples' `FAKE_MODEL=1`) so the check costs nothing.
|
|
219
|
+
4. Then write scenarios as above, with one difference: the agent's own tools run for real, so there
|
|
220
|
+
are no `tests/tools/*.yaml`, no `world` for tools and no `outcome`, `calls` or `never` checks
|
|
221
|
+
(the cloud sees no tool calls); write what must be true as `judge` items. Run `npx @flow-engineer/flow test --agent-url <url>`.
|
|
222
|
+
|
|
223
|
+
`examples/http-agent-python` and `examples/http-agent-go` are the café's agent done this way.
|