@flow-engineer/flow 0.2.3 → 0.3.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/README.md +3 -3
- package/coding-agents/skill.md +24 -9
- 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 +107 -29
- 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 +40 -10
- package/examples/cafe/src/agent.ts +3 -3
- package/examples/cafe/src/channels.ts +1 -1
- package/examples/cafe/src/main.ts +23 -8
- package/examples/cafe/src/mcp.ts +24 -6
- package/examples/cafe/src/tools.ts +1 -1
- package/package.json +1 -1
package/dist/testing/src/cli.js
CHANGED
|
@@ -32,7 +32,7 @@ import { request as httpsRequest } from "node:https";
|
|
|
32
32
|
import { basename, dirname, join } from "node:path";
|
|
33
33
|
import { pathToFileURL } from "node:url";
|
|
34
34
|
import { parseArgs } from "node:util";
|
|
35
|
-
import { hasModelKey
|
|
35
|
+
import { hasModelKey } from "../../src/model.js";
|
|
36
36
|
import { PLUGIN_LINE, pluginCli } from "./plugin.js";
|
|
37
37
|
import { OBSERVE_URL, setting } from "../../src/traces.js";
|
|
38
38
|
import { cloudHeaders, runInCloud, SessionStopped } from "./client.js";
|
|
@@ -122,10 +122,11 @@ const USAGE = `Usage:\n${NEW_LINE}\n${CHAT_LINE}\n${TEST_LINE}\n${SHOW_LINE}\n${
|
|
|
122
122
|
${PLUGIN_LINE}\n${ACCOUNT_LINES}\n flow --version Show the version\n\n${TEST_OPTIONS}`;
|
|
123
123
|
/** What to do after new or init, from step 2; a model key comes first when none is set here. */
|
|
124
124
|
function nextSteps() {
|
|
125
|
+
// link signs in and writes the keys first, FLOW_KEY (the model, on Flow's free trial credit) among
|
|
126
|
+
// them, so npm start comes after it and has the model key and the channels from its first start.
|
|
125
127
|
const steps = [
|
|
126
|
-
|
|
127
|
-
`npm start, and open the link it prints: try it in the playground as its test people, channels side by side`,
|
|
128
|
-
`npx @flow-engineer/flow link: signs in, writes the other keys, prints the link for your phone;\n keep npm start running and text it`,
|
|
128
|
+
`npx @flow-engineer/flow link --channel telegram (or whatsapp, imessage): signs in, writes the keys\n into .env (FLOW_KEY pays for the model on Flow's free trial credit; ${hasModelKey(process.env) ? "your own model key in .env still wins" : "or put your own model key in .env"}) and prints the link for your phone`,
|
|
129
|
+
`npm start, and open the link it prints: try it in the playground as its test people, channels side by side;\n keep it running and text it from your phone`,
|
|
129
130
|
`Tell your coding agent what to build: its skills are in .claude/skills/`,
|
|
130
131
|
];
|
|
131
132
|
return steps.map((step, i) => ` ${i + 2}. ${step}`).join("\n");
|
|
@@ -203,7 +204,7 @@ async function askDisplayName(given, packageName) {
|
|
|
203
204
|
}
|
|
204
205
|
const namedLine = (dir) => {
|
|
205
206
|
const displayName = JSON.parse(readFileSync(join(dir, "package.json"), "utf8")).displayName;
|
|
206
|
-
return `People call it "${displayName}" (package.json displayName): a phone reaches it with "Hi ${displayName} (ref <code>)".`;
|
|
207
|
+
return `People call it "${displayName}" (package.json displayName, and its prompt agent/AGENTS.md): a phone reaches it with "Hi ${displayName} (ref <code>)".`;
|
|
207
208
|
};
|
|
208
209
|
const displayNameFlag = values["display-name"];
|
|
209
210
|
if (command === "new") {
|
|
@@ -218,7 +219,7 @@ if (command === "new") {
|
|
|
218
219
|
console.error(error.message);
|
|
219
220
|
process.exit(1);
|
|
220
221
|
}
|
|
221
|
-
console.log(`Made ./${name}/ from the café example
|
|
222
|
+
console.log(`Made ./${name}/ from the café example. ${namedLine(dir)} Next:
|
|
222
223
|
1. cd ${name} && npm install
|
|
223
224
|
${nextSteps()}`);
|
|
224
225
|
process.exit(0);
|
|
@@ -235,7 +236,7 @@ if (command === "init") {
|
|
|
235
236
|
process.exit(1);
|
|
236
237
|
}
|
|
237
238
|
loadEnv(undefined); // a .env already here may hold a model key
|
|
238
|
-
console.log(`Made ${name}, started from the café example
|
|
239
|
+
console.log(`Made ${name}, started from the café example, in this folder. ${namedLine(process.cwd())} Next:
|
|
239
240
|
1. npm install
|
|
240
241
|
${nextSteps()}`);
|
|
241
242
|
process.exit(0);
|
|
@@ -303,7 +304,8 @@ else {
|
|
|
303
304
|
if (typeof module.buildAgent !== "function" || !Array.isArray(module.tools)) {
|
|
304
305
|
invalid("src/agent.ts must export buildAgent({ model, events, tools }) and tools, or pass --agent-url to test an agent by its HTTP endpoint.");
|
|
305
306
|
}
|
|
306
|
-
|
|
307
|
+
// A business run's agent also gets its channels and clock (testing/src/business.ts); buildAgent passes them on to createAgent.
|
|
308
|
+
agent = { tools: module.tools, build: ({ tools, events, channels, testing }) => module.buildAgent({ tools, events, ...(channels ? { channels, testing } : {}) }) };
|
|
307
309
|
}
|
|
308
310
|
/** With --agent-url the agent's own tools run for real: the suite's tool simulators do not apply. */
|
|
309
311
|
function forUrl(files) {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type Agent, type EventSink, type Tool } from "../../src/index.ts";
|
|
2
2
|
import type { Opened, RunSummary, SessionSummary } from "./protocol.ts";
|
|
3
3
|
import { type SuiteFile } from "./suite.ts";
|
|
4
|
+
import { type BusinessBuild } from "./business.ts";
|
|
4
5
|
export interface ConnectOptions {
|
|
5
6
|
/** The cloud's address, such as http://127.0.0.1:8900. */
|
|
6
7
|
cloud: string;
|
|
@@ -10,11 +11,12 @@ export interface ConnectOptions {
|
|
|
10
11
|
title?: string;
|
|
11
12
|
/** The agent's real tools: only their names, descriptions and inputs are used. */
|
|
12
13
|
tools: Tool[];
|
|
13
|
-
/** Builds the agent with the tools and events it is given: a Flow Framework Agent, or anything that answers like one (http-agent.ts).
|
|
14
|
+
/** Builds the agent with the tools and events it is given: a Flow Framework Agent, or anything that answers like one (http-agent.ts).
|
|
15
|
+
* A business run (protocol.ts) builds one more per run, with its own channels and clock (`BusinessBuild`); that needs a Flow Framework Agent. */
|
|
14
16
|
build: (options: {
|
|
15
17
|
tools: Tool[];
|
|
16
18
|
events: EventSink[];
|
|
17
|
-
}) => AgentUnderTest;
|
|
19
|
+
} & Partial<BusinessBuild>) => AgentUnderTest;
|
|
18
20
|
files: SuiteFile[];
|
|
19
21
|
/** The tests folder: files a scenario attaches are read from here, and never leave this machine. */
|
|
20
22
|
testsDir?: string;
|
|
@@ -10,6 +10,7 @@ import { toSpec } from "../../src/index.js";
|
|
|
10
10
|
import { PROFILE_TOOL, profileTool } from "../../src/conversation.js";
|
|
11
11
|
import { STAND_IN } from "../../src/tools.js";
|
|
12
12
|
import { mimeOf, parseScenario, parseToolFile, suiteHash } from "./suite.js";
|
|
13
|
+
import { businessRuns } from "./business.js";
|
|
13
14
|
/** The session opened, then the cloud ended it with a `failed` command. `code` is the reason's leading
|
|
14
15
|
* code (`capacity: ...` gives capacity), when it starts with one. */
|
|
15
16
|
export class CloudEnded extends Error {
|
|
@@ -351,6 +352,8 @@ async function playSession(options, only) {
|
|
|
351
352
|
events.context = true;
|
|
352
353
|
try {
|
|
353
354
|
const agent = options.build({ tools, events: [events] });
|
|
355
|
+
// Business runs need an agent of this framework: its own channels, routes, clock and nudges.
|
|
356
|
+
const canBusiness = typeof agent.reach === "function" && typeof agent.fetch === "function";
|
|
354
357
|
const open = {
|
|
355
358
|
agent: { name: options.name, title: options.title, tools: [...options.tools, ...(agent.hasProfile?.() ? standIn : [])].map(toSpec), channels: agent.channels() },
|
|
356
359
|
files: options.files.map(({ path, hash }) => ({ path, hash })),
|
|
@@ -359,6 +362,7 @@ async function playSession(options, only) {
|
|
|
359
362
|
retries: true,
|
|
360
363
|
resume: true,
|
|
361
364
|
reopen: true,
|
|
365
|
+
...(canBusiness ? { business: true } : {}),
|
|
362
366
|
...(only ? { only } : {}),
|
|
363
367
|
};
|
|
364
368
|
const opened = await openOnce(`${base}/api/sessions`, open, options.openHeaders, controller.signal, postTimeoutMs, openRetryCapMs, log);
|
|
@@ -382,6 +386,20 @@ async function playSession(options, only) {
|
|
|
382
386
|
options.onDashboard?.(dashboard, opened.sessionId, typeof opened.agent === "string" && opened.agent ? opened.agent : undefined);
|
|
383
387
|
log(`Watch it: ${opened.dashboard}`);
|
|
384
388
|
session = `${tunnel}/api/sessions/${opened.sessionId}`;
|
|
389
|
+
const business = canBusiness && opened.business === true
|
|
390
|
+
? businessRuns({
|
|
391
|
+
sessionAgent: agent,
|
|
392
|
+
build: (built) => options.build(built),
|
|
393
|
+
tunnelTools: tools,
|
|
394
|
+
realTools: options.tools,
|
|
395
|
+
events,
|
|
396
|
+
post: (path, body, key, what) => postRetry(`${session}/${path}`, body, auth, controller.signal, log, postTimeoutMs, {
|
|
397
|
+
mode: idempotent ? "idempotent" : path === "outbound" ? "outbound" : "never", key, budgetMs: retryBudgetMs, what,
|
|
398
|
+
}),
|
|
399
|
+
watch,
|
|
400
|
+
log,
|
|
401
|
+
})
|
|
402
|
+
: null;
|
|
385
403
|
if (failure)
|
|
386
404
|
throw failure;
|
|
387
405
|
startFlush();
|
|
@@ -447,6 +465,8 @@ async function playSession(options, only) {
|
|
|
447
465
|
const handle = async (command) => {
|
|
448
466
|
if (draining && (command.type === "message" || command.type === "reaction"))
|
|
449
467
|
throw new Error("the cloud sent a command after drain");
|
|
468
|
+
if (business && (business.handle(command, (incoming) => withFiles(incoming, options.testsDir, log))))
|
|
469
|
+
return;
|
|
450
470
|
if (command.type === "message") {
|
|
451
471
|
const deliveries = [];
|
|
452
472
|
// Not async: register the promise during receive(), before its fire-and-forget
|
|
@@ -1,3 +1,3 @@
|
|
|
1
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";
|
|
2
|
+
export { hashOf, isBusiness, loadSuite, parseScenario, parseToolFile, suiteHash, waitMs, type OpeningStep, type Scenario, type SentItem, type SuiteFile, type ToolFile } from "./suite.ts";
|
|
3
|
+
export type { BusinessCommand, BusinessNudge, BusinessOutbound, Check, Command, FlowEventEnvelope, StepAnswer, OpenSession, Opened, RunKey, RunSummary, SessionSummary, ToolCallRequest, ToolCallResult, UploadFile, Usage, WireIncoming } from "./protocol.ts";
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
export { runInCloud } from "./client.js";
|
|
2
|
-
export { hashOf, loadSuite, parseScenario, parseToolFile, suiteHash } from "./suite.js";
|
|
2
|
+
export { hashOf, isBusiness, loadSuite, parseScenario, parseToolFile, suiteHash, waitMs } from "./suite.js";
|
|
@@ -6,6 +6,15 @@ export declare function packageRoot(from?: string): string;
|
|
|
6
6
|
export declare const MAX_DISPLAY_NAME = 60;
|
|
7
7
|
/** The name people call the agent, from the package name when none was given: "my-shop" is "my shop". */
|
|
8
8
|
export declare function displayNameOf(given: string | undefined, packageName: string): string;
|
|
9
|
+
/**
|
|
10
|
+
* Renames the agent in `dir`: package.json's displayName, and in its prompt the old display name
|
|
11
|
+
* (and the café's Pip) become the new one. Returns the name and whether the prompt changed (a prompt
|
|
12
|
+
* rewritten by hand may name neither).
|
|
13
|
+
*/
|
|
14
|
+
export declare function setDisplayName(dir: string, given: string): {
|
|
15
|
+
displayName: string;
|
|
16
|
+
prompt: boolean;
|
|
17
|
+
};
|
|
9
18
|
interface MakeOptions {
|
|
10
19
|
root?: string;
|
|
11
20
|
/** What people call the agent; the package name in words when not given. */
|
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`. */
|