@flow-engineer/flow 0.2.0 → 0.2.2
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 -21
- package/README.md +190 -17
- package/coding-agents/auth-skill.md +137 -0
- package/coding-agents/context-skill.md +94 -0
- package/coding-agents/skill.md +245 -11
- package/coding-agents/test-skill.md +13 -11
- package/coding-agents/tools-skill.md +78 -1
- package/dist/cli/src/auth.d.ts +11 -0
- package/dist/cli/src/auth.js +147 -0
- package/dist/cli/src/commands.d.ts +119 -0
- package/dist/cli/src/commands.js +212 -0
- package/dist/cli/src/index.d.ts +2 -0
- package/dist/cli/src/index.js +5 -0
- package/dist/cli/src/run.d.ts +28 -0
- package/dist/cli/src/run.js +523 -0
- package/dist/cli/src/terminal.d.ts +3 -0
- package/dist/cli/src/terminal.js +135 -0
- package/dist/flow-messaging/src/client.d.ts +7 -1
- package/dist/flow-messaging/src/client.js +13 -1
- package/dist/flow-messaging/src/index.d.ts +38 -3
- package/dist/flow-messaging/src/index.js +242 -40
- package/dist/mcp/src/client.d.ts +54 -0
- package/dist/mcp/src/client.js +175 -0
- package/dist/mcp/src/describe.d.ts +41 -0
- package/dist/mcp/src/describe.js +110 -0
- package/dist/mcp/src/index.d.ts +4 -0
- package/dist/mcp/src/index.js +7 -0
- package/dist/mcp/src/server.d.ts +70 -0
- package/dist/mcp/src/server.js +252 -0
- package/dist/mcp/src/terminal.d.ts +24 -0
- package/dist/mcp/src/terminal.js +54 -0
- package/dist/simulators/src/index.d.ts +1 -1
- package/dist/simulators/src/page.html +267 -49
- package/dist/simulators/src/server.d.ts +27 -0
- package/dist/simulators/src/server.js +264 -40
- package/dist/src/agent.d.ts +69 -3
- package/dist/src/agent.js +162 -8
- package/dist/src/channels.d.ts +2 -0
- package/dist/src/conversation.d.ts +16 -1
- package/dist/src/conversation.js +140 -12
- package/dist/src/events.d.ts +1 -1
- package/dist/src/index.d.ts +2 -2
- package/dist/src/index.js +1 -1
- package/dist/src/model.d.ts +27 -0
- package/dist/src/model.js +85 -13
- package/dist/src/names.d.ts +5 -0
- package/dist/src/names.js +5 -0
- package/dist/src/skills.d.ts +4 -0
- package/dist/src/skills.js +23 -0
- package/dist/src/store.d.ts +2 -0
- package/dist/src/store.js +18 -0
- package/dist/src/tools.d.ts +69 -0
- package/dist/src/tools.js +17 -1
- package/dist/src/traces.js +32 -4
- package/dist/src/types.d.ts +2 -0
- package/dist/testing/src/account.d.ts +4 -0
- package/dist/testing/src/account.js +891 -0
- package/dist/testing/src/chat.d.ts +3 -0
- package/dist/testing/src/chat.js +125 -0
- package/dist/testing/src/check.d.ts +4 -0
- package/dist/testing/src/check.js +90 -0
- package/dist/testing/src/chinwag.js +1 -1
- package/dist/testing/src/cli.js +105 -18
- package/dist/testing/src/client.d.ts +2 -0
- package/dist/testing/src/client.js +4 -3
- package/dist/testing/src/flow-login.d.ts +176 -0
- package/dist/testing/src/flow-login.js +274 -0
- package/dist/testing/src/new.d.ts +15 -2
- package/dist/testing/src/new.js +80 -21
- package/dist/testing/src/plugin.d.ts +4 -0
- package/dist/testing/src/plugin.js +116 -0
- package/docs/agents.md +160 -0
- package/examples/cafe/src/agent.ts +6 -2
- package/examples/cafe/src/channels.ts +20 -0
- package/examples/cafe/src/cli.ts +24 -0
- package/examples/cafe/src/main.ts +17 -3
- package/examples/cafe/src/mcp.ts +22 -0
- package/examples/cafe/src/tools.ts +13 -0
- package/flow-messaging/README.md +66 -27
- package/package.json +30 -2
- package/spec/agent-endpoint.openapi.yaml +2 -2
- package/spec/nudges.openapi.yaml +2 -2
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export declare const CHAT_LINE = " flow chat [--channel <name>] [--person <phone>] [--json]\n Talk to the agent in this folder from the terminal: one message per line\n of stdin, no account needed (only the model key in .env)";
|
|
2
|
+
/** Runs `flow chat`; resolves to the exit status. */
|
|
3
|
+
export declare function chatCli(args: string[], input?: NodeJS.ReadableStream): Promise<number>;
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// `flow chat`: talk to the agent in this folder from the terminal, with no account and no channel.
|
|
2
|
+
// It builds the agent from src/agent.ts (buildAgent({ events }), as `flow test` does) with the
|
|
3
|
+
// model key in .env, and writes as the agent's first test person, the way the playground does
|
|
4
|
+
// (agent.simulate). Each line read from stdin is one message; the agent's replies are printed once
|
|
5
|
+
// its turn is over, so it works typed by a person or piped by a coding agent:
|
|
6
|
+
// printf 'hi\nA latte please\n' | npx @flow-engineer/flow chat
|
|
7
|
+
// --json prints each thing the agent sends as one JSON line, and an error as
|
|
8
|
+
// {"error":{"code","message","next"}} (no_model_key, no_agent, not_built, no_test_people,
|
|
9
|
+
// no_channel). The conversation lives only as long as the command. Exit status: 0 done (stdin
|
|
10
|
+
// ended), 1 usage or the agent could not be built.
|
|
11
|
+
var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
|
|
12
|
+
if (typeof path === "string" && /^\.\.?\//.test(path)) {
|
|
13
|
+
return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
|
|
14
|
+
return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
return path;
|
|
18
|
+
};
|
|
19
|
+
import { existsSync } from "node:fs";
|
|
20
|
+
import { join } from "node:path";
|
|
21
|
+
import { createInterface } from "node:readline";
|
|
22
|
+
import { pathToFileURL } from "node:url";
|
|
23
|
+
import { parseArgs } from "node:util";
|
|
24
|
+
import { NO_MODEL_KEY } from "../../src/model.js";
|
|
25
|
+
import { loadEnv } from "./report.js";
|
|
26
|
+
export const CHAT_LINE = ` flow chat [--channel <name>] [--person <phone>] [--json]
|
|
27
|
+
Talk to the agent in this folder from the terminal: one message per line
|
|
28
|
+
of stdin, no account needed (only the model key in .env)`;
|
|
29
|
+
const USAGE = `Usage:\n${CHAT_LINE}
|
|
30
|
+
|
|
31
|
+
Writes as the agent's first test person (createAgent({ testPeople })) on its first channel, unless
|
|
32
|
+
--person or --channel says otherwise. Exit status: 0 done, 1 usage or the agent could not be built.`;
|
|
33
|
+
/** Runs `flow chat`; resolves to the exit status. */
|
|
34
|
+
export async function chatCli(args, input = process.stdin) {
|
|
35
|
+
let values;
|
|
36
|
+
try {
|
|
37
|
+
({ values } = parseArgs({ args, strict: true, options: { channel: { type: "string" }, person: { type: "string" }, json: { type: "boolean", default: false }, help: { type: "boolean", short: "h", default: false } } }));
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
console.error(`flow chat: ${error.message.split(". ")[0]}\n\n${USAGE}`);
|
|
41
|
+
return 1;
|
|
42
|
+
}
|
|
43
|
+
if (values.help) {
|
|
44
|
+
console.log(USAGE);
|
|
45
|
+
return 0;
|
|
46
|
+
}
|
|
47
|
+
loadEnv(undefined);
|
|
48
|
+
const json = values.json === true;
|
|
49
|
+
/** Stops with exit 1: the sentence on stderr, and with --json the error as one JSON line on stdout. */
|
|
50
|
+
const fail = (code, message, next, nextLine = next ? `Next: ${next}` : undefined) => {
|
|
51
|
+
console.error(`flow chat: ${message}${nextLine ? `\n${nextLine}` : ""}`);
|
|
52
|
+
if (json)
|
|
53
|
+
process.stdout.write(`${JSON.stringify({ error: { code, message, next: next ?? null } })}\n`);
|
|
54
|
+
return 1;
|
|
55
|
+
};
|
|
56
|
+
const file = join(process.cwd(), "src", "agent.ts");
|
|
57
|
+
if (!existsSync(file))
|
|
58
|
+
return fail("no_agent", "There is no src/agent.ts here: run it from your agent's folder.", "npx @flow-engineer/flow init", "Next: npx @flow-engineer/flow init makes one in an empty folder.");
|
|
59
|
+
const turns = [];
|
|
60
|
+
let agent;
|
|
61
|
+
try {
|
|
62
|
+
const module = await import(__rewriteRelativeImportExtension(pathToFileURL(file).href));
|
|
63
|
+
if (typeof module.buildAgent !== "function")
|
|
64
|
+
throw new Error("src/agent.ts must export buildAgent({ events }).");
|
|
65
|
+
agent = module.buildAgent({ events: [(event) => turns.forEach((listener) => listener(event))] });
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
if (error.code === NO_MODEL_KEY.code)
|
|
69
|
+
return fail(NO_MODEL_KEY.code, NO_MODEL_KEY.message, NO_MODEL_KEY.next, NO_MODEL_KEY.nextLine);
|
|
70
|
+
return fail("not_built", error.message);
|
|
71
|
+
}
|
|
72
|
+
const person = values.person ? agent.testPeople().find((p) => p.phone.replace(/\D/g, "") === values.person.replace(/\D/g, "")) : agent.testPeople()[0];
|
|
73
|
+
const channel = values.channel ? agent.channels().find((c) => c.name === values.channel) : agent.channels()[0];
|
|
74
|
+
if (!person)
|
|
75
|
+
return fail("no_test_people", `${values.person ? `${values.person} is not one of the agent's testPeople` : "the agent has no testPeople"}; declare them in createAgent({ testPeople: [{ name, phone }] }).`);
|
|
76
|
+
if (!channel)
|
|
77
|
+
return fail("no_channel", `${values.channel ? `no channel named ${values.channel}` : "the agent has no channels"}; it has ${agent.channels().map((c) => c.name).join(", ") || "none"}.`);
|
|
78
|
+
agent.attachPlayground(({ outbound }) => print(outbound, channel.name, json));
|
|
79
|
+
turns.push((event) => {
|
|
80
|
+
if (event.type === "model.error")
|
|
81
|
+
console.error(`flow chat: the model failed: ${String(event.error ?? event.message ?? "error")}`);
|
|
82
|
+
});
|
|
83
|
+
const prompt = process.stdin.isTTY && input === process.stdin;
|
|
84
|
+
if (!json)
|
|
85
|
+
console.error(`Talking to the agent on ${channel.name} as ${person.name} (${person.phone}). Ctrl-D ends.`);
|
|
86
|
+
const lines = createInterface({ input, terminal: false });
|
|
87
|
+
if (prompt)
|
|
88
|
+
process.stderr.write("you: ");
|
|
89
|
+
let conversationId;
|
|
90
|
+
for await (const line of lines) {
|
|
91
|
+
const text = line.trim();
|
|
92
|
+
if (text) {
|
|
93
|
+
const taken = await agent.simulate({ person: person.phone, channel: channel.name, message: text });
|
|
94
|
+
if (taken)
|
|
95
|
+
await agent.conversation((conversationId = taken.conversationId))?.whenIdle();
|
|
96
|
+
}
|
|
97
|
+
if (prompt)
|
|
98
|
+
process.stderr.write("you: ");
|
|
99
|
+
}
|
|
100
|
+
// A tool still running in the background answers in a turn of its own: wait for it before leaving.
|
|
101
|
+
while (conversationId && agent.status().backgroundTasks > 0) {
|
|
102
|
+
await new Promise((done) => turns.push((event) => event.type === "task.done" && done()));
|
|
103
|
+
await agent.conversation(conversationId)?.whenIdle();
|
|
104
|
+
}
|
|
105
|
+
await agent.flushTraces();
|
|
106
|
+
return 0;
|
|
107
|
+
}
|
|
108
|
+
/** One thing the agent sent, as a person would read it (or one JSON line). */
|
|
109
|
+
function print(outbound, channel, json) {
|
|
110
|
+
if (outbound.type === "typing" || outbound.type === "status" || outbound.type === "read")
|
|
111
|
+
return;
|
|
112
|
+
if (json)
|
|
113
|
+
return void process.stdout.write(`${JSON.stringify({ channel, ...outbound })}\n`);
|
|
114
|
+
if (outbound.type === "reaction")
|
|
115
|
+
return void process.stdout.write(`agent reacted ${outbound.emoji}\n`);
|
|
116
|
+
if (outbound.type === "template")
|
|
117
|
+
return void process.stdout.write(`agent: [template ${outbound.name}]\n`);
|
|
118
|
+
const extra = [
|
|
119
|
+
outbound.image && `[photo ${outbound.image.url}]`,
|
|
120
|
+
outbound.link && `[${outbound.link.label}: ${outbound.link.url}]`,
|
|
121
|
+
outbound.buttons?.map((b) => `[${b}]`).join(" "),
|
|
122
|
+
outbound.list && `[${outbound.list.button}: ${outbound.list.rows.map((r) => r.title).join(" | ")}]`,
|
|
123
|
+
].filter(Boolean);
|
|
124
|
+
process.stdout.write(`agent: ${[outbound.text, ...extra].filter(Boolean).join(" ")}\n`);
|
|
125
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export declare const CHECK_LINE = " flow check [--json] [--plugin <dir>] Check the agent's tools, CLI file and skills for MCP and the CLI";
|
|
2
|
+
export declare function checkCli(argv: string[], dir?: string): Promise<number>;
|
|
3
|
+
/** `flow mcp update`: loads the agent with every mcpTools call writing its server's current tools to mcp.lock.json. */
|
|
4
|
+
export declare function mcpUpdateCli(dir?: string): Promise<number>;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// `flow check`, from an agent's folder: whether its tools can be served over MCP exactly as
|
|
2
|
+
// declared, whether its CLI file (src/cli.ts) still matches them, and whether the skills it would
|
|
3
|
+
// send to MCP hosts name tools only chats have. Rules only, no model: what it reports is
|
|
4
|
+
// objectively wrong (errors, exit 1) or worth a look (warnings).
|
|
5
|
+
var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
|
|
6
|
+
if (typeof path === "string" && /^\.\.?\//.test(path)) {
|
|
7
|
+
return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
|
|
8
|
+
return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
|
|
9
|
+
});
|
|
10
|
+
}
|
|
11
|
+
return path;
|
|
12
|
+
};
|
|
13
|
+
import { existsSync } from "node:fs";
|
|
14
|
+
import { pluginProblems } from "./plugin.js";
|
|
15
|
+
import { join, resolve } from "node:path";
|
|
16
|
+
import { pathToFileURL } from "node:url";
|
|
17
|
+
import { cliProblems } from "../../cli/src/commands.js";
|
|
18
|
+
import { CHAT_ONLY, describe, toolsProblems } from "../../mcp/src/describe.js";
|
|
19
|
+
import { loadSkills, readAgentFolder } from "../../src/skills.js";
|
|
20
|
+
export const CHECK_LINE = ` flow check [--json] [--plugin <dir>] Check the agent's tools, CLI file and skills for MCP and the CLI`;
|
|
21
|
+
export async function checkCli(argv, dir = process.cwd()) {
|
|
22
|
+
const json = argv.includes("--json");
|
|
23
|
+
const errors = [];
|
|
24
|
+
const warnings = [];
|
|
25
|
+
const pluginAt = argv.indexOf("--plugin");
|
|
26
|
+
const pluginDir = pluginAt >= 0 ? argv[pluginAt + 1] : undefined;
|
|
27
|
+
if (pluginAt >= 0 && (!pluginDir || pluginDir.startsWith("--")))
|
|
28
|
+
errors.push("--plugin needs its generated folder");
|
|
29
|
+
if (pluginDir)
|
|
30
|
+
errors.push(...pluginProblems(dir, resolve(dir, pluginDir)));
|
|
31
|
+
const agentFile = join(dir, "src/agent.ts");
|
|
32
|
+
if (!existsSync(agentFile)) {
|
|
33
|
+
console.error(`No src/agent.ts in ${dir}. Run flow check from the agent's folder.`);
|
|
34
|
+
return 1;
|
|
35
|
+
}
|
|
36
|
+
const agent = await import(__rewriteRelativeImportExtension(pathToFileURL(agentFile).href));
|
|
37
|
+
const tools = Array.isArray(agent.tools) ? agent.tools : [];
|
|
38
|
+
if (!Array.isArray(agent.tools))
|
|
39
|
+
errors.push("src/agent.ts exports no tools array");
|
|
40
|
+
errors.push(...toolsProblems(tools));
|
|
41
|
+
if (existsSync(join(dir, "agent"))) {
|
|
42
|
+
for (const skill of loadSkills(readAgentFolder(join(dir, "agent")))) {
|
|
43
|
+
const named = CHAT_ONLY.exec(skill.body);
|
|
44
|
+
if (named)
|
|
45
|
+
(pluginAt >= 0 ? errors : warnings).push(`skill ${skill.name}: names ${named[1]}, which MCP hosts and the CLI do not have`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
const cliFile = join(dir, "src/cli.ts");
|
|
49
|
+
if (existsSync(cliFile)) {
|
|
50
|
+
const cli = (await import(__rewriteRelativeImportExtension(pathToFileURL(cliFile).href))).cli;
|
|
51
|
+
if (!cli)
|
|
52
|
+
errors.push("src/cli.ts exports no cli (export const cli = defineCli({ ... }))");
|
|
53
|
+
else {
|
|
54
|
+
const described = tools.flatMap((t) => {
|
|
55
|
+
try {
|
|
56
|
+
return [describe(t)];
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return [];
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
const found = cliProblems(cli, described);
|
|
63
|
+
errors.push(...found.errors.map((e) => `src/cli.ts: ${e}`));
|
|
64
|
+
warnings.push(...found.warnings.map((w) => `src/cli.ts: ${w}`));
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (json)
|
|
68
|
+
console.log(JSON.stringify({ ok: errors.length === 0, errors, warnings }));
|
|
69
|
+
else {
|
|
70
|
+
for (const e of errors)
|
|
71
|
+
console.log(`error ${e}`);
|
|
72
|
+
for (const w of warnings)
|
|
73
|
+
console.log(`warn ${w}`);
|
|
74
|
+
console.log(errors.length ? `${errors.length} error${errors.length === 1 ? "" : "s"}.` : `No errors in ${tools.length} tool${tools.length === 1 ? "" : "s"}.`);
|
|
75
|
+
}
|
|
76
|
+
return errors.length ? 1 : 0;
|
|
77
|
+
}
|
|
78
|
+
/** `flow mcp update`: loads the agent with every mcpTools call writing its server's current tools to mcp.lock.json. */
|
|
79
|
+
export async function mcpUpdateCli(dir = process.cwd()) {
|
|
80
|
+
const agentFile = join(dir, "src/agent.ts");
|
|
81
|
+
if (!existsSync(agentFile)) {
|
|
82
|
+
console.error(`No src/agent.ts in ${dir}. Run flow mcp update from the agent's folder.`);
|
|
83
|
+
return 1;
|
|
84
|
+
}
|
|
85
|
+
const { UPDATE_ENV } = await import("../../mcp/src/client.js");
|
|
86
|
+
process.env[UPDATE_ENV] = "1";
|
|
87
|
+
await import(__rewriteRelativeImportExtension(pathToFileURL(agentFile).href));
|
|
88
|
+
console.error(`Updated ${join(dir, "mcp.lock.json")}. Run flow check and the tests, then commit it.`);
|
|
89
|
+
return 0;
|
|
90
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// The `chinwag` command from before the rename, kept for one release: it says so on stderr (stdout
|
|
3
3
|
// stays the report's, for --json), then runs the flow command (testing/src/cli.ts) as it is.
|
|
4
|
-
console.error("chinwag is now Flow Framework (@flow-engineer/flow): run npx flow, and npm i @flow-engineer/flow.");
|
|
4
|
+
console.error("chinwag is now Flow Framework (@flow-engineer/flow): run npx @flow-engineer/flow, and npm i @flow-engineer/flow.");
|
|
5
5
|
await import("./cli.js");
|
|
6
6
|
export {};
|
package/dist/testing/src/cli.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// The flow command. `flow new <name>` makes ./<name>/, an agent started from the café example
|
|
3
|
-
// (testing/src/new.ts). `flow
|
|
2
|
+
// The flow command. `flow new <name>` makes ./<name>/, an agent started from the café example, and
|
|
3
|
+
// `flow init` the same agent in the current folder (testing/src/new.ts). `flow chat` talks to the
|
|
4
|
+
// agent in this folder from the terminal (testing/src/chat.ts). `flow test`, or `flow` with flags, runs an agent's tests in the cloud,
|
|
4
5
|
// from the agent's folder:
|
|
5
6
|
// flow test [--cloud https://flow.engineer/observe] [--runs 3] [--only late-order,refund | --failed] [--json]
|
|
6
7
|
// A hosted cloud (any --cloud off this machine, the default included) needs the
|
|
@@ -15,6 +16,8 @@
|
|
|
15
16
|
// (what each run ended as and the judge's reasons, never the messages), and --failed runs again the
|
|
16
17
|
// scenarios that did not pass. --json prints one report (testing/src/report.ts) on stdout at every exit,
|
|
17
18
|
// and `flow show <run>` prints one run's record from the cloud (testing/src/show.ts).
|
|
19
|
+
// `flow login`, `logout`, `whoami`, `setup` and `link` sign in to flow.engineer and get the agent's
|
|
20
|
+
// keys and its phone link (testing/src/account.ts).
|
|
18
21
|
var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
|
|
19
22
|
if (typeof path === "string" && /^\.\.?\//.test(path)) {
|
|
20
23
|
return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
|
|
@@ -29,22 +32,38 @@ import { request as httpsRequest } from "node:https";
|
|
|
29
32
|
import { basename, dirname, join } from "node:path";
|
|
30
33
|
import { pathToFileURL } from "node:url";
|
|
31
34
|
import { parseArgs } from "node:util";
|
|
35
|
+
import { hasModelKey, NO_MODEL_KEY } from "../../src/model.js";
|
|
36
|
+
import { PLUGIN_LINE, pluginCli } from "./plugin.js";
|
|
32
37
|
import { OBSERVE_URL, setting } from "../../src/traces.js";
|
|
33
38
|
import { cloudHeaders, runInCloud, SessionStopped } from "./client.js";
|
|
34
39
|
import { runPrinter } from "./print-run.js";
|
|
35
40
|
import { checkEndpointCli } from "./check-endpoint.js";
|
|
41
|
+
import { CHECK_LINE as TOOLS_CHECK_LINE, checkCli, mcpUpdateCli } from "./check.js";
|
|
42
|
+
import { ACCOUNT_COMMANDS, ACCOUNT_LINES, accountCli } from "./account.js";
|
|
43
|
+
import { CHAT_LINE, chatCli } from "./chat.js";
|
|
36
44
|
import { httpAgent } from "./http-agent.js";
|
|
37
|
-
import {
|
|
45
|
+
import { createInterface } from "node:readline/promises";
|
|
46
|
+
import { displayNameOf, folderName, initHere, newStandalone, packageRoot } from "./new.js";
|
|
38
47
|
import { EXIT, finishedOutcome, loadEnv, networkCode, reportError, runVerdict, stoppedOutcome } from "./report.js";
|
|
39
48
|
import { LAST_SESSION, lastSessionIn, SHOW_LINE, showCli } from "./show.js";
|
|
40
49
|
import { loadSuite, parseScenario, parseToolFile, suiteHash } from "./suite.js";
|
|
41
|
-
const NEW_LINE =
|
|
50
|
+
const NEW_LINE = ` flow new <name> [--display-name <name>]
|
|
51
|
+
Make ./<name>/, a new agent started from the café example
|
|
52
|
+
flow init [--display-name <name>]
|
|
53
|
+
Make that agent in the current folder, named after it.
|
|
54
|
+
--display-name is what people call it, such as "FlatX agent": a
|
|
55
|
+
phone reaches it with "Hi FlatX agent (ref <code>)" (asked for when
|
|
56
|
+
run in a terminal; else the folder's name in words)`;
|
|
42
57
|
const TEST_LINE = ` flow test [options] Run the agent's tests in a test cloud, from the agent's folder
|
|
43
58
|
(src/agent.ts exports buildAgent({ model, events, tools }) and tools;
|
|
44
59
|
the tests are in tests/scenarios and tests/tools)`;
|
|
45
60
|
const CHECK_LINE = ` flow check-endpoint <url> [--token-env NAME] [--json]
|
|
46
61
|
Check an agent's HTTP endpoint against the contract, for --agent-url
|
|
47
62
|
(flow check-endpoint --help)`;
|
|
63
|
+
const MCP_LINE = ` flow mcp update Write the tools of the MCP servers the agent uses (mcpTools) to
|
|
64
|
+
mcp.lock.json, from the agent's folder; commit it after the tests`;
|
|
65
|
+
const CLI_LINE = ` flow cli --server <url> <command>
|
|
66
|
+
Any MCP server's tools as commands (flow cli --server <url> --help)`;
|
|
48
67
|
const NEW_USAGE = `Usage:\n${NEW_LINE}\n\n -h, --help Show this help`;
|
|
49
68
|
const TEST_OPTIONS = `Options for test:
|
|
50
69
|
--cloud <url> The test cloud (default: FLOW_FRAMEWORK_CLOUD, else https://flow.engineer/observe)
|
|
@@ -91,15 +110,29 @@ with the judge's reasons. Never the messages. The folder is git-ignored and the
|
|
|
91
110
|
Environment (also read from the agent folder's .env, or --dotenv; variables already set win;
|
|
92
111
|
the CHINWAG_ names from before the rename still work):
|
|
93
112
|
FLOW_FRAMEWORK_CLOUD The test cloud, when --cloud is not given
|
|
94
|
-
FLOW_FRAMEWORK_CLOUD_KEY The agent's CI key, for a hosted cloud (oci_...:
|
|
95
|
-
https://flow.engineer/observe, your agent, Developers)
|
|
113
|
+
FLOW_FRAMEWORK_CLOUD_KEY The agent's CI key, for a hosted cloud (oci_...: flow setup
|
|
114
|
+
writes it, or make one at https://flow.engineer/observe, your agent, Developers)
|
|
96
115
|
FLOW_FRAMEWORK_AGENT_TOKEN
|
|
97
116
|
With --agent-url: sent to it as authorization: Bearer <token>
|
|
98
117
|
OPENROUTER_API_KEY The agent's model key, or OPENAI_API_KEY, ANTHROPIC_API_KEY or
|
|
99
|
-
GEMINI_API_KEY: the first with a value is used
|
|
118
|
+
GEMINI_API_KEY: the first with a value is used, else FLOW_KEY
|
|
119
|
+
(the agent's Flow key, on its trial credit; flow setup writes it)`;
|
|
100
120
|
const TEST_USAGE = `Usage:\n${TEST_LINE}\n\n${TEST_OPTIONS}`;
|
|
101
|
-
const USAGE = `Usage:\n${NEW_LINE}\n${TEST_LINE}\n${SHOW_LINE}\n${CHECK_LINE}\n
|
|
121
|
+
const USAGE = `Usage:\n${NEW_LINE}\n${CHAT_LINE}\n${TEST_LINE}\n${SHOW_LINE}\n${CHECK_LINE}\n${TOOLS_CHECK_LINE}\n${MCP_LINE}\n${CLI_LINE}
|
|
122
|
+
${PLUGIN_LINE}\n${ACCOUNT_LINES}\n flow --version Show the version\n\n${TEST_OPTIONS}`;
|
|
123
|
+
/** What to do after new or init, from step 2; a model key comes first when none is set here. */
|
|
124
|
+
function nextSteps() {
|
|
125
|
+
const steps = [
|
|
126
|
+
...(hasModelKey(process.env) ? [] : [`Model key: none yet. ${NO_MODEL_KEY.nextLine.replace(/^Next: r/, "R")} (in .env: cp .env.example .env; the file says where to make one)`]),
|
|
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`,
|
|
129
|
+
`Tell your coding agent what to build: its skills are in .claude/skills/`,
|
|
130
|
+
];
|
|
131
|
+
return steps.map((step, i) => ` ${i + 2}. ${step}`).join("\n");
|
|
132
|
+
}
|
|
102
133
|
checkNode(process.versions.node);
|
|
134
|
+
if (process.argv[2] === "plugin")
|
|
135
|
+
process.exit(pluginCli(process.argv.slice(3)));
|
|
103
136
|
if (process.argv[2] === "check-endpoint") {
|
|
104
137
|
// Its token may be in the agent folder's .env; no value is ever printed.
|
|
105
138
|
loadEnv(undefined);
|
|
@@ -107,6 +140,24 @@ if (process.argv[2] === "check-endpoint") {
|
|
|
107
140
|
}
|
|
108
141
|
if (process.argv[2] === "show")
|
|
109
142
|
process.exit(await showCli(process.argv.slice(3)));
|
|
143
|
+
if (process.argv[2] === "check")
|
|
144
|
+
process.exit(await checkCli(process.argv.slice(3)));
|
|
145
|
+
if (process.argv[2] === "mcp") {
|
|
146
|
+
if (process.argv[3] !== "update") {
|
|
147
|
+
console.error(MCP_LINE.trim());
|
|
148
|
+
process.exit(1);
|
|
149
|
+
}
|
|
150
|
+
process.exit(await mcpUpdateCli());
|
|
151
|
+
}
|
|
152
|
+
if (process.argv[2] === "cli") {
|
|
153
|
+
// Loaded only here: @flow-engineer/flow/cli needs @modelcontextprotocol/client, an optional peer.
|
|
154
|
+
const { defineCli, runCli } = await import("../../cli/src/index.js");
|
|
155
|
+
process.exit(await runCli(defineCli({ name: "flow" }), process.argv.slice(3)));
|
|
156
|
+
}
|
|
157
|
+
if (process.argv[2] === "chat")
|
|
158
|
+
process.exit(await chatCli(process.argv.slice(3)));
|
|
159
|
+
if (ACCOUNT_COMMANDS.has(process.argv[2] ?? ""))
|
|
160
|
+
process.exit(await accountCli(process.argv[2], process.argv.slice(3)));
|
|
110
161
|
const TEST_FLAGS = {
|
|
111
162
|
cloud: { type: "string" },
|
|
112
163
|
runs: { type: "string", default: "1" },
|
|
@@ -134,23 +185,59 @@ if (values.version) {
|
|
|
134
185
|
process.exit(0);
|
|
135
186
|
}
|
|
136
187
|
if (values.help || process.argv.length <= 2) {
|
|
137
|
-
console.log(command === "new" ? NEW_USAGE : command === "test" ? TEST_USAGE : USAGE);
|
|
188
|
+
console.log(command === "new" || command === "init" ? NEW_USAGE : command === "test" ? TEST_USAGE : USAGE);
|
|
138
189
|
process.exit(0);
|
|
139
190
|
}
|
|
191
|
+
/** --display-name, else asked for in a terminal (Enter takes the folder's name in words), else that default. */
|
|
192
|
+
async function askDisplayName(given, packageName) {
|
|
193
|
+
if (given !== undefined || !process.stdin.isTTY || !process.stdout.isTTY)
|
|
194
|
+
return given;
|
|
195
|
+
const fallback = displayNameOf(undefined, packageName);
|
|
196
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
197
|
+
try {
|
|
198
|
+
return (await rl.question(`What should people call your agent? Their phone says "Hi <it>" (Enter for "${fallback}"): `)).trim() || undefined;
|
|
199
|
+
}
|
|
200
|
+
finally {
|
|
201
|
+
rl.close();
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
const namedLine = (dir) => {
|
|
205
|
+
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
|
+
};
|
|
208
|
+
const displayNameFlag = values["display-name"];
|
|
140
209
|
if (command === "new") {
|
|
141
210
|
const name = rest.length === 1 ? rest[0] : "";
|
|
211
|
+
let dir;
|
|
142
212
|
try {
|
|
143
|
-
|
|
213
|
+
if (!name)
|
|
214
|
+
throw new Error("Usage: flow new <name> [--display-name <name>]");
|
|
215
|
+
dir = newStandalone(name, { displayName: await askDisplayName(displayNameFlag, name) });
|
|
144
216
|
}
|
|
145
217
|
catch (error) {
|
|
146
|
-
console.error(
|
|
218
|
+
console.error(error.message);
|
|
147
219
|
process.exit(1);
|
|
148
220
|
}
|
|
149
|
-
console.log(`Made ./${name}/ from the café example (Pip). Next:
|
|
221
|
+
console.log(`Made ./${name}/ from the café example (Pip). ${namedLine(dir)} Next:
|
|
150
222
|
1. cd ${name} && npm install
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
223
|
+
${nextSteps()}`);
|
|
224
|
+
process.exit(0);
|
|
225
|
+
}
|
|
226
|
+
if (command === "init") {
|
|
227
|
+
let name;
|
|
228
|
+
try {
|
|
229
|
+
if (rest.length)
|
|
230
|
+
throw new Error("Usage: flow init [--display-name <name>] (it makes the agent in the current folder; flow new <name> makes a folder for it)");
|
|
231
|
+
name = initHere(process.cwd(), { displayName: await askDisplayName(displayNameFlag, folderName(process.cwd())) });
|
|
232
|
+
}
|
|
233
|
+
catch (error) {
|
|
234
|
+
console.error(error.message);
|
|
235
|
+
process.exit(1);
|
|
236
|
+
}
|
|
237
|
+
loadEnv(undefined); // a .env already here may hold a model key
|
|
238
|
+
console.log(`Made ${name}, started from the café example (Pip), in this folder. ${namedLine(process.cwd())} Next:
|
|
239
|
+
1. npm install
|
|
240
|
+
${nextSteps()}`);
|
|
154
241
|
process.exit(0);
|
|
155
242
|
}
|
|
156
243
|
/** Every human line of `flow test`: stdout, or stderr with --json, where stdout is the report. */
|
|
@@ -183,7 +270,7 @@ try {
|
|
|
183
270
|
});
|
|
184
271
|
}
|
|
185
272
|
catch (error) {
|
|
186
|
-
const where = !ciKey && cloud.replace(/\/+$/, "") === OBSERVE_URL ?
|
|
273
|
+
const where = !ciKey && cloud.replace(/\/+$/, "") === OBSERVE_URL ? " Run npx @flow-engineer/flow login, then npx @flow-engineer/flow setup, to get one into .env." : "";
|
|
187
274
|
invalid(`${error.message}${where}`);
|
|
188
275
|
}
|
|
189
276
|
const dir = process.cwd();
|
|
@@ -499,8 +586,8 @@ function stoppedReason(error, cloud) {
|
|
|
499
586
|
}
|
|
500
587
|
/** The command's flags, or exits 1 with what is wrong and that command's usage. */
|
|
501
588
|
function parseCommandLine() {
|
|
502
|
-
const isNew = process.argv[2] === "new";
|
|
503
|
-
const options = isNew ? { help: TEST_FLAGS.help, version: TEST_FLAGS.version } : TEST_FLAGS;
|
|
589
|
+
const isNew = process.argv[2] === "new" || process.argv[2] === "init";
|
|
590
|
+
const options = isNew ? { help: TEST_FLAGS.help, version: TEST_FLAGS.version, "display-name": { type: "string" } } : TEST_FLAGS;
|
|
504
591
|
try {
|
|
505
592
|
return parseArgs({ allowPositionals: true, strict: true, options: options });
|
|
506
593
|
}
|
|
@@ -34,6 +34,8 @@ export interface ConnectOptions {
|
|
|
34
34
|
retryBudgetMs?: number;
|
|
35
35
|
/** How long a lost stream keeps trying to resume, on a cloud that resumes (Opened.resume). Default 2 minutes, as long as the cloud holds its commands. */
|
|
36
36
|
resumeWindowMs?: number;
|
|
37
|
+
/** The clock the resume window is measured on. Default Date.now; a test passes one it moves itself. */
|
|
38
|
+
now?: () => number;
|
|
37
39
|
/** How often a resumable stream is replaced, before the tunnel's one-hour request cap. Default 50 minutes. */
|
|
38
40
|
streamRefreshMs?: number;
|
|
39
41
|
/** The longest Retry-After a busy cloud's refused open is sent again after, once. Default 60s. */
|
|
@@ -204,6 +204,7 @@ async function playSession(options, only) {
|
|
|
204
204
|
const retryBudgetMs = options.retryBudgetMs ?? RETRY_BUDGET_MS;
|
|
205
205
|
const openRetryCapMs = options.openRetryCapMs ?? OPEN_RETRY_CAP_MS;
|
|
206
206
|
const resumeWindowMs = options.resumeWindowMs ?? RESUME_WINDOW_MS;
|
|
207
|
+
const now = options.now ?? Date.now;
|
|
207
208
|
const streamRefreshMs = options.streamRefreshMs ?? STREAM_REFRESH_MS;
|
|
208
209
|
let idempotent = false;
|
|
209
210
|
const base = options.cloud.replace(/\/$/, "");
|
|
@@ -536,13 +537,13 @@ async function playSession(options, only) {
|
|
|
536
537
|
/** The stream opened again after the last command handled, with backoff, until the resume window closes. */
|
|
537
538
|
const reconnect = async (why) => {
|
|
538
539
|
log(`stream ${why}; resuming after command ${lastId}`);
|
|
539
|
-
const deadline =
|
|
540
|
+
const deadline = now() + resumeWindowMs;
|
|
540
541
|
let last;
|
|
541
542
|
for (let attempt = 0;; attempt++) {
|
|
542
543
|
const backoff = attempt === 0 ? 0 : Math.min(10_000, 250 * 2 ** attempt) * (0.8 + Math.random() * 0.4);
|
|
543
544
|
const refused = last instanceof Error && last.cause instanceof PostError ? last.cause : undefined;
|
|
544
545
|
const wait = Math.max(backoff, refused?.retryAfterMs ?? 0);
|
|
545
|
-
if (
|
|
546
|
+
if (now() + wait >= deadline) {
|
|
546
547
|
const detail = last instanceof Error ? `; last try: ${last.message}` : "";
|
|
547
548
|
throw new StreamLost(`the stream did not resume within ${resumeWindowMs / 1000}s (${why})${detail}`);
|
|
548
549
|
}
|
|
@@ -550,7 +551,7 @@ async function playSession(options, only) {
|
|
|
550
551
|
if (failure)
|
|
551
552
|
throw failure;
|
|
552
553
|
try {
|
|
553
|
-
return await openStream(streamUrl, { ...auth, "last-event-id": String(lastId) }, controller.signal, Math.max(1, Math.min(postTimeoutMs, deadline -
|
|
554
|
+
return await openStream(streamUrl, { ...auth, "last-event-id": String(lastId) }, controller.signal, Math.max(1, Math.min(postTimeoutMs, deadline - now())));
|
|
554
555
|
}
|
|
555
556
|
catch (error) {
|
|
556
557
|
if (failure)
|