mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
218 lines
9.5 KiB
TypeScript
218 lines
9.5 KiB
TypeScript
// The fake provider of demo and test mode (P2_FAKE, `startServer({ fake: true })`): prompts that make the faux model
|
|
// call a tool (SCRIPTED and the `<tool> <json>` forms), one-shot agents' sample answers, and echoes otherwise.
|
|
import { appendFileSync } from "node:fs";
|
|
import type { Message } from "@earendil-works/pi-ai";
|
|
import { type CreateModelsOptions, createModels } from "@earendil-works/pi-ai/models";
|
|
import {
|
|
fauxAssistantMessage,
|
|
fauxProvider,
|
|
fauxText,
|
|
fauxThinking,
|
|
fauxToolCall,
|
|
} from "@earendil-works/pi-ai/providers/faux";
|
|
import { SENDER_PREFIX } from "@p2/app/v1";
|
|
|
|
// Fake-mode prompts that make the faux model call one tool; anything else is echoed.
|
|
const SCRIPTED: Record<string, () => ReturnType<typeof fauxToolCall>> = {
|
|
tool: () => fauxToolCall("read", { path: "fixture.txt" }),
|
|
write: () => fauxToolCall("write", { path: "created.txt", content: "before\n" }),
|
|
edit: () => fauxToolCall("edit", { path: "created.txt", edits: [{ oldText: "before", newText: "after" }] }),
|
|
bash: () => fauxToolCall("bash", { command: "pwd" }),
|
|
"send-file": () => fauxToolCall("send_file", { path: "created.txt" }),
|
|
artifact: () =>
|
|
fauxToolCall("artifact", {
|
|
name: "demo",
|
|
title: "Demo artifact",
|
|
type: "html",
|
|
content: "<!doctype html><title>Demo</title><h1>Artifact demo</h1>",
|
|
}),
|
|
subagent: () =>
|
|
fauxToolCall("subagent", {
|
|
action: "spawn",
|
|
name: "helper",
|
|
message: "hello helper",
|
|
thinkingLevel: "high",
|
|
tools: ["read"],
|
|
}),
|
|
"subagent-configure": () =>
|
|
fauxToolCall("subagent", {
|
|
action: "configure",
|
|
name: "helper",
|
|
model: "faux/faux-1",
|
|
thinkingLevel: "high",
|
|
tools: ["bash", "artifact"],
|
|
}),
|
|
"subagent-second": () =>
|
|
fauxToolCall("subagent", { action: "spawn", name: "second", message: "hello second", tools: ["read"] }),
|
|
"subagent-answer": () => fauxToolCall("subagent", { action: "answer", name: "helper" }),
|
|
"subagent-send": () => fauxToolCall("subagent", { action: "send", name: "helper", message: "hello again" }),
|
|
"subagent-archive": () => fauxToolCall("subagent", { action: "archive", name: "helper" }),
|
|
"subagent-status": () => fauxToolCall("subagent", { action: "status" }),
|
|
"subagent-bad": () =>
|
|
fauxToolCall("subagent", { action: "spawn", name: "bad", message: "x", model: "nope/missing" }),
|
|
// Code mode: a script whose nested calls are two tools (codemode plugin).
|
|
codemode: () =>
|
|
fauxToolCall("codemode", {
|
|
code: [
|
|
'const written = await tools.bash({ command: "echo first >> codemode.log; printf written" }).then((result) => result.output);',
|
|
'const read = await tools.read({ path: "codemode.log" });',
|
|
'text("bash: " + written);',
|
|
"return read;",
|
|
].join("\n"),
|
|
}),
|
|
// Code mode across a worker restart: the slow middle call is interrupted, the script catches it and continues.
|
|
"codemode-restart": () =>
|
|
fauxToolCall("codemode", {
|
|
code: [
|
|
'const one = (await tools.bash({ command: "echo one >> codemode.log; printf one" })).output;',
|
|
"let two;",
|
|
'try { two = (await tools.bash({ command: "sleep 5; echo two >> codemode.log; printf two" })).output; }',
|
|
'catch (error) { two = "two failed: " + error.message; }',
|
|
'const three = (await tools.bash({ command: "echo three >> codemode.log; printf three" })).output;',
|
|
'return [one, two, three].join(" | ");',
|
|
].join("\n"),
|
|
}),
|
|
"slow-tool": () => fauxToolCall("bash", { command: "printf tool-running; sleep 4; printf tool-finished" }),
|
|
};
|
|
|
|
/** The system prompt of a one-shot agent (plugins/agent) ends its instructions with its schema on this line's end. */
|
|
const ONE_SHOT_SCHEMA = "matching this JSON Schema, without prose or code fences:\n";
|
|
|
|
/** The text of a user message, without the sender block in front. */
|
|
function userText(message: Message | undefined): string {
|
|
if (message?.role !== "user") return "";
|
|
if (typeof message.content === "string") return message.content;
|
|
const first = message.content[0];
|
|
const parts =
|
|
first?.type === "text" && first.text.startsWith(SENDER_PREFIX) ? message.content.slice(1) : message.content;
|
|
return parts.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n");
|
|
}
|
|
|
|
/** A one-shot agent's answer schema, from its conversation's system messages. */
|
|
function oneShotSchema(messages: readonly Message[]): unknown {
|
|
for (const message of messages) {
|
|
if (message.role !== "system") continue;
|
|
const texts = [
|
|
typeof message.content === "string" ? message.content : message.content.map((part) => part.text).join(""),
|
|
...Object.values(message.sections ?? {}).flatMap((section) => (section ? [section] : [])),
|
|
];
|
|
for (const text of texts) {
|
|
const at = text.indexOf(ONE_SHOT_SCHEMA);
|
|
if (at >= 0) return JSON.parse(text.slice(at + ONE_SHOT_SCHEMA.length).split("\n")[0] ?? "null");
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
/**
|
|
* A small value matching a JSON Schema: the first enum value, every property, one array item, empty strings and
|
|
* minimums. Enough for code mode workflow scripts to run end to end in fake mode (docs/workflows.md).
|
|
*/
|
|
function sampleOf(schema: unknown): unknown {
|
|
if (typeof schema !== "object" || schema === null) return null;
|
|
const spec = schema as Record<string, unknown>;
|
|
if (Array.isArray(spec.enum)) return spec.enum[0];
|
|
if ("const" in spec) return spec.const;
|
|
switch (spec.type) {
|
|
case "object":
|
|
return Object.fromEntries(
|
|
Object.entries((spec.properties ?? {}) as Record<string, unknown>).map(([key, value]) => [
|
|
key,
|
|
sampleOf(value),
|
|
]),
|
|
);
|
|
case "array":
|
|
return spec.maxItems === 0 ? [] : [sampleOf(spec.items)];
|
|
case "string":
|
|
return "x".repeat(typeof spec.minLength === "number" ? spec.minLength : 0);
|
|
case "integer":
|
|
case "number":
|
|
return typeof spec.minimum === "number" ? spec.minimum : 0;
|
|
case "boolean":
|
|
return false;
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// Explicit demo/test mode. Responses depend on committed input, not a volatile
|
|
// queue position, so reopening an interrupted request is deterministic.
|
|
// Tests set P2_FAKE_REQUESTS (via workerEnv) to a file that receives each request's messages as one JSON line.
|
|
export function createFakeModels(options: CreateModelsOptions = {}) {
|
|
const requests = process.env.P2_FAKE_REQUESTS;
|
|
const models = createModels(options);
|
|
const faux = fauxProvider({ api: "p2-faux", tokensPerSecond: 200, tokenSize: { min: 4, max: 4 } });
|
|
models.setProvider({
|
|
...faux.provider,
|
|
streamSimple(model, context, options) {
|
|
if (requests) {
|
|
const messages = context.messages.map((message) => ({
|
|
role: message.role,
|
|
content:
|
|
typeof message.content === "string"
|
|
? message.content
|
|
: message.content.map((part) =>
|
|
part.type === "image" ? { type: "image", mimeType: part.mimeType } : part,
|
|
),
|
|
}));
|
|
appendFileSync(requests, `${JSON.stringify(messages)}\n`);
|
|
}
|
|
faux.appendResponses([
|
|
(transcript) => {
|
|
const text = userText(transcript.messages.findLast((message) => message.role === "user"));
|
|
// A one-shot agent answers a small value matching its schema, unless its task is a `json …` or `slow…`
|
|
// test prompt (exact answers, retries with non-JSON echoes, a long stream to stop).
|
|
const schema = oneShotSchema(transcript.messages);
|
|
const task = userText(transcript.messages.find((message) => message.role === "user"));
|
|
if (schema !== undefined && !task.startsWith("json ") && !task.startsWith("slow"))
|
|
return fauxAssistantMessage([fauxText(JSON.stringify(sampleOf(schema)))]);
|
|
// A section that changed during the run (tools loaded by `tool_search`) adds a system message after the result.
|
|
const last = transcript.messages.findLast((message) => message.role !== "system");
|
|
if (last?.role !== "toolResult") {
|
|
const call = Object.hasOwn(SCRIPTED, text)
|
|
? SCRIPTED[text]?.()
|
|
: text.startsWith("conversations ")
|
|
? fauxToolCall("conversations", JSON.parse(text.slice("conversations ".length)))
|
|
: text.startsWith("rename-session ")
|
|
? fauxToolCall("rename_session", { title: text.slice("rename-session ".length) })
|
|
: text.startsWith("ports ")
|
|
? fauxToolCall("ports", JSON.parse(text.slice("ports ".length)))
|
|
: // `codemode <script>` runs that script (the bare `codemode` above is a fixed one).
|
|
text.startsWith("codemode ")
|
|
? fauxToolCall("codemode", { code: text.slice("codemode ".length) })
|
|
: text.startsWith("subagent ")
|
|
? fauxToolCall("subagent", JSON.parse(text.slice("subagent ".length)))
|
|
: text.startsWith("team ")
|
|
? fauxToolCall("team", JSON.parse(text.slice("team ".length)))
|
|
: /^(artifact|history_(search|read|handoff)|location_(envs|dirs|move)|tool_search|terminal|mcp__\w+) /.test(
|
|
text,
|
|
)
|
|
? fauxToolCall(
|
|
text.slice(0, text.indexOf(" ")),
|
|
JSON.parse(text.slice(text.indexOf(" ") + 1)),
|
|
)
|
|
: undefined;
|
|
if (call)
|
|
return fauxAssistantMessage(
|
|
[fauxText(text === "tool" ? "Reading fixture." : `Running ${call.name}.`), call],
|
|
{ stopReason: "toolUse" },
|
|
);
|
|
}
|
|
return fauxAssistantMessage([
|
|
fauxThinking("private-thinking-sentinel"),
|
|
// `json <text>` answers exactly `<text>` (code mode's one-shot agents parse it).
|
|
fauxText(
|
|
text.startsWith("json ")
|
|
? text.slice("json ".length)
|
|
: text.startsWith("slow")
|
|
? `Echo: ${text} ${"streaming ".repeat(300)}`
|
|
: `Echo: ${text}`,
|
|
),
|
|
]);
|
|
},
|
|
]);
|
|
return faux.provider.streamSimple(model, context, options);
|
|
},
|
|
});
|
|
return models;
|
|
}
|