mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
feat(p2): subagents can use code mode
codemode is choosable for subagents and in their default tools while the codemode setting is on; one-shot agents never get it. A subagent with code mode gets the script-only tools (MCP, terminal, agent, models.*) by name in its Durable tools array, at spawn/configure and from the worker when the registry's script-only tools change.
This commit is contained in:
parent
617d0ef549
commit
474416d70b
7 changed files with 317 additions and 24 deletions
|
|
@ -894,7 +894,7 @@ translated phone drawer and intercept chat taps.
|
|||
section. Call: `tools.terminal({ command, args })`, tmux's argv after the command name, parsed getopt-style
|
||||
(`commands.ts`: no string parsing, tmux-like errors such as `send-keys: unknown option -- x` or `usage: …`):
|
||||
`new-window [-n name] [-c dir] [-w W] [-T s] [command ...]` (a shell at the calling conversation's location,
|
||||
`environments.locate`; subagents cannot choose code mode or this tool yet; `-c` relative to it; `command` is typed into it, then Enter, and the shell stays),
|
||||
`environments.locate`: a subagent's script opens it at the subagent's own location; `-c` relative to it; `command` is typed into it, then Enter, and the shell stays),
|
||||
`list-windows`, `send-keys -t T [-l] [-w W] [-T s] key ...` (tmux key names as keys, encoded by libghostty in the
|
||||
terminal's modes, other arguments as text), `read -t T [-w W] [-T s]` (output since the agent's last read),
|
||||
`capture-pane -t T [-S -N] [-e] [-p]` (the screen, N scrollback lines, `-e` with SGR colors), `kill-window -t T`.
|
||||
|
|
@ -1037,9 +1037,22 @@ translated phone drawer and intercept chat taps.
|
|||
the list does not start/hold a worker. The sidebar expands children (latest entry first), with working dots;
|
||||
`/s/<id>/c/<conversation>` opens an independent chat, draft and model control. A parent link returns to the root.
|
||||
Browser configuration synchronizes the subagent registry via `ConversationConfigured`. Main Stop leaves child
|
||||
work running; direct child prompts do not report to the parent. `test/p2/subagents.test.ts` covers bad models,
|
||||
configured spawn, one report, answer retrieval, configuration, independent history, reload during child work,
|
||||
idle restart, guest reads and sidebar persistence across server restart.
|
||||
work running; direct child prompts do not report to the parent. **Tools** (`SUBAGENT_TOOLS` in `contract.worker.ts`,
|
||||
validated by `resolveChoice` before any commit): `tools` may name the coding tools, `codemode` and the location tools;
|
||||
the default is the coding tools and `codemode`, as the main agent has code mode while the `codemode` setting is on
|
||||
(the default leaves out what is not installed: code mode while the setting is off or without the plugin; naming an
|
||||
uninstalled tool is refused, "Not available in this session: codemode."). `team` and the history tools come on
|
||||
top. Durable enables exactly the names in a conversation's `tools` array, and code mode's nested calls resolve among
|
||||
the enabled tools, so a subagent with code mode also gets the script-only tools (`callers: ["tools"]`: MCP tools,
|
||||
`terminal`, `agent`, code mode's `models.*`) by name: at spawn/configure those installed then (`withExtras`), and
|
||||
later ones (an MCP server connecting) from the worker, which follows the registry's script-only tools and appends
|
||||
missing names to every code mode subagent's array (`enableScriptTools`; a tool task resolves its agent once per
|
||||
phase, so this cannot happen inside the script's own call). Names are never removed: Durable ignores a name whose
|
||||
tool is gone. A subagent's scripts can thus start one-shot agents (which get no code mode). `test/p2/subagents.test.ts`
|
||||
covers bad models, configured spawn, one report, answer retrieval, configuration, independent history, reload during
|
||||
child work, idle restart, guest reads, sidebar persistence across server restart, and code mode: choosing and
|
||||
validating it, the default with the setting on and off, a script calling a coding tool and `agent`, and MCP tools
|
||||
installed after the spawn; `terminal-tool.test.ts` a subagent's script opening a window at its own location.
|
||||
- **Code mode** (`plugins/codemode`): the worker facet registers Durable's `codemode` extension
|
||||
(`createCodemodeExtension`, tool `codemode`, callers `["model"]`, replay-safe) in `Extensions`. One sandbox per worker
|
||||
(`CodemodeSandbox`, 256 MiB VM heap: `CODEMODE_MEMORY_LIMIT_BYTES`, kept constant because it is part of the VM
|
||||
|
|
@ -1121,7 +1134,8 @@ translated phone drawer and intercept chat taps.
|
|||
`structuredOutput` is the value, `details` `{ conversationId, attempts }`; nothing is reported to the parent. It has no
|
||||
prompt section (as Pi Mobile 1b763d912): its description, Pi Mobile's verbatim, carries the usage notes, and code
|
||||
mode's section lists it with the other tools scripts call. Model/thinking/tool
|
||||
validation is the subagent tool's `resolveChoice`. The plugin contributes the tool unconditionally and does not
|
||||
validation is the subagent tool's `resolveChoice` with `ONE_SHOT_TOOLS`: the coding and location tools, never
|
||||
`codemode` (a one-shot agent's own scripts could start one-shot agents again, fanning out without bound). The plugin contributes the tool unconditionally and does not
|
||||
depend on code mode: without a script nothing can call it (`callers: ["tools"]`), so code mode stays removable while
|
||||
it is loaded (code mode's listing then disappears; `agent.test.ts` removes and re-adds code mode). It depends on subagents,
|
||||
whose document lists its conversations. The creating commit
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ import { formats } from "@earendil-works/pi-schema/formats";
|
|||
import type { JsonValue } from "@p2/app/v1";
|
||||
import { record } from "@p2/app/v1";
|
||||
import { isTopLevel } from "@p2/app/v1/worker";
|
||||
import { resolveChoice, Subagents } from "../subagents/contract.worker.ts";
|
||||
import { ONE_SHOT_TOOLS, resolveChoice, Subagents } from "../subagents/contract.worker.ts";
|
||||
|
||||
export const AGENT_TOOL = "agent";
|
||||
const ATTEMPTS = 3;
|
||||
|
|
@ -72,7 +72,10 @@ export function createAgentExtension(models: Models) {
|
|||
}
|
||||
// A rerun reattaches to the conversation it created, even if its model has since left the configuration.
|
||||
const existing = (await api.snapshot(AgentRun, api.taskId, context))?.conversationId;
|
||||
const choice = existing === undefined ? await resolveChoice(models, api, args, undefined, context) : undefined;
|
||||
const choice =
|
||||
existing === undefined
|
||||
? await resolveChoice(models, api, args, undefined, ONE_SHOT_TOOLS, context)
|
||||
: undefined;
|
||||
if (typeof choice === "string") return fail(choice);
|
||||
// Owned by this call: aborting the call aborts the agent's work. Listed in the same commit, by the call ID the
|
||||
// script's tool card shows it under, so the user can open its transcript.
|
||||
|
|
|
|||
|
|
@ -21,8 +21,21 @@ import type { Context, Location } from "@p2/app/v1";
|
|||
export const CODING_TOOLS = ["read", "write", "edit", "bash", "send_file", "artifact"];
|
||||
/** The location tools (plugins/locations): every main agent has them; a subagent only when chosen. */
|
||||
export const LOCATION_TOOLS = ["location_envs", "location_dirs", "location_move"];
|
||||
/** What a subagent's `tools` may name: the coding tools (its default) and the location tools. */
|
||||
export const CHOOSABLE = [...CODING_TOOLS, ...LOCATION_TOOLS];
|
||||
/** The code mode tool (plugins/codemode), installed while the `codemode` setting is on. */
|
||||
export const CODEMODE_TOOL = "codemode";
|
||||
|
||||
/** The tools an agent may choose (`choosable`) and gets without a choice (`defaults`), by name. */
|
||||
export type ToolOffer = { readonly choosable: readonly string[]; readonly defaults: readonly string[] };
|
||||
/**
|
||||
* A subagent's: the coding tools and code mode by default (code mode only while installed, as for the main agent), the
|
||||
* location tools when chosen.
|
||||
*/
|
||||
export const SUBAGENT_TOOLS: ToolOffer = {
|
||||
choosable: [...CODING_TOOLS, CODEMODE_TOOL, ...LOCATION_TOOLS],
|
||||
defaults: [...CODING_TOOLS, CODEMODE_TOOL],
|
||||
};
|
||||
/** A one-shot agent's (plugins/agent): no code mode, so a script's agents cannot run scripts that fan out again. */
|
||||
export const ONE_SHOT_TOOLS: ToolOffer = { choosable: [...CODING_TOOLS, ...LOCATION_TOOLS], defaults: CODING_TOOLS };
|
||||
export const THINKING = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
|
||||
|
||||
export const TEAM_TOOL = "team";
|
||||
|
|
@ -107,14 +120,16 @@ export const Subagents = defineDoc<SubagentState>({
|
|||
export type AgentChoice = { model?: string; thinkingLevel?: string; tools?: string[] };
|
||||
|
||||
/**
|
||||
* Validate a choice before any commit, so a typo changes nothing; an error is returned as text. Unset fields keep
|
||||
* `current`'s, or for a new agent the caller's model and thinking level and every coding tool.
|
||||
* Validate a choice before any commit, so a typo changes nothing; an error is returned as text. `offer` says which tools
|
||||
* it may name. Unset fields keep `current`'s, or for a new agent the caller's model and thinking level and the offer's
|
||||
* defaults that are installed. A chosen tool that is not installed (code mode while its setting is off) is an error.
|
||||
*/
|
||||
export async function resolveChoice(
|
||||
models: Models,
|
||||
api: Pick<ToolExecutionApi, "agent" | "registry">,
|
||||
choice: AgentChoice,
|
||||
current: Subagent | undefined,
|
||||
offer: ToolOffer,
|
||||
context: Context,
|
||||
): Promise<{ model: ModelRef; thinkingLevel: ModelThinkingLevel; tools: ToolRegistration[] } | string> {
|
||||
const own = current ? undefined : await api.agent(context);
|
||||
|
|
@ -135,10 +150,13 @@ export async function resolveChoice(
|
|||
current?.thinkingLevel ??
|
||||
own?.thinkingLevel ??
|
||||
"off") as ModelThinkingLevel;
|
||||
const names = (choice.tools ?? current?.tools ?? CODING_TOOLS).filter((tool) => !ALWAYS.includes(tool));
|
||||
const unknown = names.filter((tool) => !CHOOSABLE.includes(tool));
|
||||
if (unknown.length) return `Unknown tools: ${unknown.join(", ")}. Choose from ${CHOOSABLE.join(", ")}.`;
|
||||
const names = (choice.tools ?? current?.tools ?? offer.defaults).filter((tool) => !ALWAYS.includes(tool));
|
||||
const unknown = names.filter((tool) => !offer.choosable.includes(tool));
|
||||
if (unknown.length) return `Unknown tools: ${unknown.join(", ")}. Choose from ${offer.choosable.join(", ")}.`;
|
||||
const installed = api.registry.tools().map((entry) => entry.tool);
|
||||
const missing =
|
||||
choice.tools === undefined ? [] : names.filter((tool) => !installed.some((each) => each.name === tool));
|
||||
if (missing.length) return `Not available in this session: ${missing.join(", ")}.`;
|
||||
return {
|
||||
model: ref,
|
||||
thinkingLevel: clampThinkingLevel(model, requested),
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
import type { AssistantMessage, ModelThinkingLevel } from "@earendil-works/pi-ai";
|
||||
import type { Models } from "@earendil-works/pi-ai/models";
|
||||
import {
|
||||
AgentDoc,
|
||||
AssistantEntry,
|
||||
type ConversationHandle,
|
||||
type ConversationId,
|
||||
|
|
@ -29,7 +30,14 @@ import type { Context, JsonValue } from "@p2/app/v1";
|
|||
import { type Location, type LocationInput, literal, locationText } from "@p2/app/v1";
|
||||
import { mainConversation, originOf } from "@p2/app/v1/worker";
|
||||
import { AGENT_MESSAGE_ENTRY, type AgentMessage, SUBAGENT_TASK_ENTRY, SubagentLocationsSchema } from "./contract.ts";
|
||||
import { HISTORY_TOOLS, resolveChoice, Subagents, TEAM_TOOL } from "./contract.worker.ts";
|
||||
import {
|
||||
CODEMODE_TOOL,
|
||||
HISTORY_TOOLS,
|
||||
resolveChoice,
|
||||
SUBAGENT_TOOLS,
|
||||
Subagents,
|
||||
TEAM_TOOL,
|
||||
} from "./contract.worker.ts";
|
||||
|
||||
const SUBAGENT_EXTENSION = "subagent-tools";
|
||||
|
||||
|
|
@ -362,9 +370,10 @@ export function createSubagentExtension(models: Models, locator: SubagentLocator
|
|||
" env is an environment name from location_envs, or null for this device (default: yours; another environment needs",
|
||||
" the owner's request).",
|
||||
" model is 'provider/modelId' (default: yours); thinkingLevel is off|minimal|low|medium|high|xhigh|max",
|
||||
" (clamped to the model); tools is a subset of read, write, edit, bash, send_file, artifact (default: all of these;",
|
||||
" [] for none) and the location tools location_envs, location_dirs, location_move (never by default; with",
|
||||
" location_move it can move itself).",
|
||||
" (clamped to the model); tools is a subset of read, write, edit, bash, send_file, artifact, codemode (default: all",
|
||||
" of these, codemode while code mode is on; [] for none) and the location tools location_envs, location_dirs,",
|
||||
" location_move (never by default; with location_move it can move itself). With codemode its scripts call its",
|
||||
" chosen tools and the tools behind code mode (MCP tools, terminal, one-shot agents).",
|
||||
"- send (name, message; followUp: true queues after the current answer instead of steering).",
|
||||
"- configure (name; optional model, thinkingLevel, tools, env, cwd, followSession): replace its model/thinking/tool",
|
||||
" selection for later work; env/cwd give it its own location, followSession: true makes it follow the session",
|
||||
|
|
@ -488,7 +497,8 @@ export function createSubagentExtension(models: Models, locator: SubagentLocator
|
|||
if (action === "spawn" || action === "configure") {
|
||||
if (action === "spawn" && agent)
|
||||
return reply(`${name} already exists${agent.archived ? " (archived)" : ""}; use send.`, {}, true);
|
||||
const choice = await resolveChoice(models, api, args, action === "spawn" ? undefined : agent, context);
|
||||
const existing = action === "spawn" ? undefined : agent;
|
||||
const choice = await resolveChoice(models, api, args, existing, SUBAGENT_TOOLS, context);
|
||||
if (typeof choice === "string") return reply(choice, {}, true);
|
||||
change = { model: choice.model, thinkingLevel: choice.thinkingLevel };
|
||||
tools = choice.tools;
|
||||
|
|
@ -600,11 +610,47 @@ export function createSubagentExtension(models: Models, locator: SubagentLocator
|
|||
|
||||
/**
|
||||
* A subagent's tools plus the ones every subagent has: `team`, which is deferred (only conversations whose `modelTools`
|
||||
* adds it are offered it), and the history tools among `installed`.
|
||||
* adds it are offered it), and the history tools among `installed`; with code mode also the tools behind it among
|
||||
* `installed` (`scriptOnly`; the worker adds ones installed later, see `enableScriptTools`).
|
||||
*/
|
||||
function withExtras(tools: readonly ToolRegistration[], installed: readonly ToolRegistration[]) {
|
||||
const history = HISTORY_TOOLS.flatMap((name) => installed.filter((tool) => tool.name === name).slice(0, 1));
|
||||
return { tools: [...tools, teamTool, ...history], modelTools: { add: [teamTool] } };
|
||||
const behind = tools.some((tool) => tool.name === CODEMODE_TOOL) ? installed.filter(scriptOnly) : [];
|
||||
return { tools: [...tools, teamTool, ...history, ...behind], modelTools: { add: [teamTool] } };
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether only code mode scripts call `tool` (`callers: ["tools"]`: MCP tools, `terminal`, `agent`, code mode's own
|
||||
* `models.*`). Durable enables exactly the tools a conversation's `tools` array names, and nested calls resolve among
|
||||
* the enabled ones, so a subagent's array must name them for its scripts to reach them.
|
||||
*/
|
||||
export const scriptOnly = (tool: ToolRegistration): boolean => tool.callers?.includes("model") === false;
|
||||
|
||||
/**
|
||||
* Adds the tools behind code mode installed now (`names`) to every subagent with code mode, by name: MCP tools come and
|
||||
* go with their servers, so the names written at spawn go stale. It runs between scripts, not inside one: a script's
|
||||
* call resolves the agent once, at its start. A name whose tool goes away is ignored by Durable and works again when it
|
||||
* returns, so none is removed.
|
||||
*/
|
||||
export async function enableScriptTools(
|
||||
harness: Pick<Harness, "snapshot" | "commit">,
|
||||
names: readonly string[],
|
||||
context: Context,
|
||||
): Promise<void> {
|
||||
const registry = await harness.snapshot(Subagents, ROOT_CONVERSATION_ID, context);
|
||||
const lacking: ConversationId[] = [];
|
||||
for (const agent of Object.values(registry?.agents ?? {})) {
|
||||
if (!agent.tools.includes(CODEMODE_TOOL)) continue;
|
||||
const enabled = (await harness.snapshot(AgentDoc, agent.conversationId, context))?.tools;
|
||||
if (Array.isArray(enabled) && names.some((name) => !enabled.includes(name))) lacking.push(agent.conversationId);
|
||||
}
|
||||
if (lacking.length === 0) return;
|
||||
await harness.commit(async (tx) => {
|
||||
for (const conversationId of lacking) {
|
||||
const state = await tx.doc(AgentDoc, conversationId);
|
||||
if (Array.isArray(state.tools)) state.tools = [...new Set([...state.tools, ...names])];
|
||||
}
|
||||
}, context);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { setTimeout as sleep } from "node:timers/promises";
|
|||
import type { Models } from "@earendil-works/pi-ai/models";
|
||||
import { type ConversationId, ROOT_CONVERSATION_ID } from "@earendil-works/pi-durable";
|
||||
import { canSend, contribute, defineFacet, expectVariant, forbidden, signalOf } from "@p2/app/v1";
|
||||
import { durableContext } from "@p2/app/v1/node";
|
||||
import { durableContext, ExtensionSwitches } from "@p2/app/v1/node";
|
||||
import {
|
||||
CommitSignal,
|
||||
ConversationConfigured,
|
||||
|
|
@ -21,8 +21,10 @@ import { type Child, SubagentControl, Subagents } from "./contract.ts";
|
|||
import { ALWAYS, Subagents as Registry } from "./contract.worker.ts";
|
||||
import {
|
||||
createSubagentExtension,
|
||||
enableScriptTools,
|
||||
ownLocation,
|
||||
placeSubagents,
|
||||
scriptOnly,
|
||||
setArchived,
|
||||
spawnEnvironments,
|
||||
teamExtension,
|
||||
|
|
@ -60,6 +62,18 @@ export default defineFacet((host, self) => {
|
|||
directory: (target, from, context) => resolveDirectory(harness().environments, target, from, context),
|
||||
});
|
||||
self.contribute(Extensions, { [extension.name]: extension, [teamExtension.name]: teamExtension });
|
||||
// The tools behind code mode, as the Harness's registry installs them (switched-off extensions left out).
|
||||
const scriptTools = self.derive(
|
||||
[self.registry(Extensions), self.registry(ExtensionSwitches)],
|
||||
([extensions, switches]) =>
|
||||
[...extensions]
|
||||
.filter(([name]) => switches.get(name) !== false)
|
||||
.flatMap(([, each]) => (each.tools ?? []).filter(scriptOnly).map((tool) => tool.name))
|
||||
.sort(),
|
||||
);
|
||||
self.on(scriptTools, async ({ value }, ctx) => {
|
||||
await enableScriptTools(harness().durable, value, durableContext(ctx));
|
||||
});
|
||||
self.contribute(ConversationConfigured, {
|
||||
subagents: async (conversation, ctx) => {
|
||||
if (conversation.id === ROOT_CONVERSATION_ID) return;
|
||||
|
|
|
|||
|
|
@ -2,10 +2,12 @@ import assert from "node:assert/strict";
|
|||
import { mkdir, readFile, realpath, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { after, before, describe, it } from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { ToolResultMessage } from "@earendil-works/pi-ai";
|
||||
import type { ConversationView } from "@earendil-works/pi-durable";
|
||||
import { BACKGROUND_CONTEXT, whenLive } from "@p2/app/v1";
|
||||
import { Chat } from "../../plugins/chat/contract.ts";
|
||||
import { Mcp } from "../../plugins/mcp/contract.ts";
|
||||
import { Sessions } from "../../plugins/sessions/contract.ts";
|
||||
import { Sharing } from "../../plugins/sharing/contract.ts";
|
||||
import { SessionStatus } from "../../plugins/status/contract.ts";
|
||||
|
|
@ -15,6 +17,8 @@ import { startServer } from "../../src/hosts/server.ts";
|
|||
import type { Host, RefIn } from "../../src/runtime/chord.ts";
|
||||
import { expectChord, type Fixture, failureOf, holdIn, http, startFixture, until } from "./helpers.ts";
|
||||
|
||||
const MCP_FIXTURE = fileURLToPath(new URL("../fixtures/mcp-server.mjs", import.meta.url));
|
||||
|
||||
const texts = (view: ConversationView | null | undefined): string[] =>
|
||||
(view?.entries ?? []).flatMap((entry) =>
|
||||
(entry.model ?? []).flatMap((message) =>
|
||||
|
|
@ -325,3 +329,172 @@ describe("a subagent spawned with its own cwd (fake provider)", () => {
|
|||
children.release();
|
||||
});
|
||||
});
|
||||
|
||||
describe("subagents with code mode (fake provider)", () => {
|
||||
let fixture: Fixture;
|
||||
let owner: Host;
|
||||
let chat: RefIn<typeof Chat, "participant">;
|
||||
let children: RefIn<typeof Subagents, "reader">;
|
||||
let session: string;
|
||||
let sent = 0;
|
||||
/** Sends `subagent <args>` and waits for that call's result. */
|
||||
const subagent = async (args: object): Promise<ToolResultMessage> => {
|
||||
const count = results(chat.view.value).length;
|
||||
await chat.send(
|
||||
{ text: `subagent ${JSON.stringify(args)}`, requestId: `codemode-${++sent}` },
|
||||
BACKGROUND_CONTEXT,
|
||||
);
|
||||
return results(await until(chat.view, (view) => results(view).length > count)).at(-1)!;
|
||||
};
|
||||
/** Waits until `name`'s answer was reported and the main agent's turn on it ended, so it cannot race a later send. */
|
||||
const reported = (name: string) =>
|
||||
until(
|
||||
chat.view,
|
||||
(view) =>
|
||||
texts(view).some((each) => each.startsWith(`Echo: [subagent ${name} answered`)) &&
|
||||
!(view?.docs["pi.live"] as { run?: unknown } | undefined)?.run,
|
||||
);
|
||||
const results = (view: ConversationView | null | undefined) =>
|
||||
(view?.entries ?? [])
|
||||
.flatMap((entry) => entry.model ?? [])
|
||||
.filter(
|
||||
(message): message is ToolResultMessage => message.role === "toolResult" && message.toolName === "subagent",
|
||||
);
|
||||
const text = (message: ToolResultMessage | undefined) =>
|
||||
(message?.content ?? []).flatMap((part) => (part.type === "text" ? [part.text] : [])).join("");
|
||||
|
||||
before(async () => {
|
||||
fixture = await startFixture();
|
||||
await writeFile(join(fixture.workspace, "marker.txt"), "inside\n");
|
||||
owner = fixture.browser();
|
||||
session = (
|
||||
await (await holdIn(owner, Sessions, {}, "owner")).create({ cwd: fixture.workspace }, BACKGROUND_CONTEXT)
|
||||
).id;
|
||||
chat = await holdIn(owner, Chat, { session, conversation: 1 }, "participant");
|
||||
children = await holdIn(owner, Subagents, {}, "reader");
|
||||
});
|
||||
after(async () => {
|
||||
await fixture?.close();
|
||||
});
|
||||
|
||||
it("validates codemode as a choosable tool", async () => {
|
||||
const bad = await subagent({ action: "spawn", name: "bad", message: "x", tools: ["codemode", "bogus"] });
|
||||
assert.equal(bad.isError, true);
|
||||
assert.equal(
|
||||
text(bad),
|
||||
"Unknown tools: bogus. Choose from read, write, edit, bash, send_file, artifact, codemode, location_envs, location_dirs, location_move.",
|
||||
);
|
||||
const chosen = await subagent({ action: "spawn", name: "chosen", message: "x", tools: ["bash", "codemode"] });
|
||||
assert.deepEqual((chosen.details as { tools?: string[] }).tools, ["bash", "codemode"]);
|
||||
await reported("chosen");
|
||||
});
|
||||
|
||||
it("gives code mode by default; a script calls its tools and the tools behind code mode", async () => {
|
||||
// The subagent's script reads with a chosen coding tool, then starts a one-shot agent (`agent` is script-only:
|
||||
// `callers: ["tools"]`), whose tools may not include code mode.
|
||||
const script = [
|
||||
'const read = await tools.read({ path: "marker.txt" });',
|
||||
'const answer = await tools.agent({ prompt: \'json {"n":1}\', schema: { type: "object" } });',
|
||||
"let refused;",
|
||||
'try { await tools.agent({ prompt: "x", schema: {}, tools: ["codemode"] }); } catch (error) { refused = String(error.message ?? error); }',
|
||||
"return { read, answer, refused };",
|
||||
].join("\n");
|
||||
const spawned = await subagent({ action: "spawn", name: "coder", message: `codemode ${script}` });
|
||||
assert.equal(spawned.isError, false, text(spawned));
|
||||
assert.deepEqual((spawned.details as { tools?: string[] }).tools, [
|
||||
"read",
|
||||
"write",
|
||||
"edit",
|
||||
"bash",
|
||||
"send_file",
|
||||
"artifact",
|
||||
"codemode",
|
||||
]);
|
||||
const listed = await until(children.children, (value) =>
|
||||
Boolean(value?.[session]?.some((each) => each.name === "coder")),
|
||||
);
|
||||
const coder = listed![session]!.find((each) => each.name === "coder")!;
|
||||
const sub = await holdIn(owner, Chat, { session, conversation: coder.conversationId }, "participant");
|
||||
const view = await until(sub.view, (value) =>
|
||||
(value?.entries ?? []).some((entry) =>
|
||||
entry.model?.some((message) => message.role === "toolResult" && message.toolName === "codemode"),
|
||||
),
|
||||
);
|
||||
const ran = (view?.entries ?? [])
|
||||
.flatMap((entry) => entry.model ?? [])
|
||||
.find(
|
||||
(message): message is ToolResultMessage => message.role === "toolResult" && message.toolName === "codemode",
|
||||
);
|
||||
assert.equal(ran?.isError, false, text(ran));
|
||||
const output = text(ran);
|
||||
assert.match(output, /^Script completed/);
|
||||
const value = JSON.parse(output.slice(output.indexOf("{"))) as { read: string; answer: unknown; refused: string };
|
||||
assert.match(value.read, /inside/);
|
||||
assert.deepEqual(value.answer, { n: 1 });
|
||||
assert.match(
|
||||
value.refused,
|
||||
/Unknown tools: codemode\. Choose from read, write, edit, bash, send_file, artifact, location_envs/,
|
||||
);
|
||||
// The script-only tools were enabled by name before the script started; the model is still offered its choice.
|
||||
const tools = (sub.view.value?.docs["pi.agent"] as { tools: string[] }).tools;
|
||||
assert.ok(tools.includes("agent"), tools.join());
|
||||
await reported("coder");
|
||||
await until(children.children, (value) =>
|
||||
Boolean(value?.[session]?.find((each) => each.name === "coder")?.tools.includes("codemode")),
|
||||
);
|
||||
sub.release();
|
||||
});
|
||||
|
||||
it("reaches MCP tools installed after the spawn", async () => {
|
||||
// The server connects after `coder` was spawned: the worker adds its tools to the subagent's tool list.
|
||||
await writeFile(
|
||||
join(fixture.dataDir, "mcp.json"),
|
||||
JSON.stringify({ mcpServers: { files: { command: process.execPath, args: [MCP_FIXTURE] } } }),
|
||||
);
|
||||
const mcp = await holdIn(owner, Mcp, {}, "owner");
|
||||
await until(mcp.instances, (instances) => instances.some((item) => item.status === "connected"));
|
||||
const coder = (await until(children.children, (value) => Boolean(value?.[session]?.length)))![session]!.find(
|
||||
(each) => each.name === "coder",
|
||||
)!;
|
||||
const sub = await holdIn(owner, Chat, { session, conversation: coder.conversationId }, "participant");
|
||||
const scripts = () =>
|
||||
(sub.view.value?.entries ?? [])
|
||||
.flatMap((entry) => entry.model ?? [])
|
||||
.filter(
|
||||
(message): message is ToolResultMessage =>
|
||||
message.role === "toolResult" && message.toolName === "codemode",
|
||||
);
|
||||
const before = scripts().length;
|
||||
const script = 'return await tools.mcp__files__echo({ text: "from coder" });';
|
||||
await subagent({ action: "send", name: "coder", message: `codemode ${script}` });
|
||||
await until(sub.view, () => scripts().length > before);
|
||||
const ran = scripts().at(-1);
|
||||
assert.equal(ran?.isError, false, text(ran));
|
||||
assert.match(text(ran), /echo: from coder$/);
|
||||
await reported("coder");
|
||||
sub.release();
|
||||
mcp.release();
|
||||
});
|
||||
|
||||
it("leaves code mode out while the codemode setting is off, and refuses choosing it", async () => {
|
||||
await writeFile(join(fixture.dataDir, "settings.json"), JSON.stringify({ codemode: false }));
|
||||
// The worker applies the settings file asynchronously: spawn (a new name each time) until it refuses.
|
||||
let refused: ToolResultMessage | undefined;
|
||||
for (let attempt = 0; attempt < 20 && !refused?.isError; attempt++) {
|
||||
if (attempt > 0) await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
refused = await subagent({ action: "spawn", name: `off-${attempt}`, message: "x", tools: ["codemode"] });
|
||||
if (!refused.isError) await reported(`off-${attempt}`);
|
||||
}
|
||||
assert.equal(refused?.isError, true);
|
||||
assert.equal(text(refused), "Not available in this session: codemode.");
|
||||
const plain = await subagent({ action: "spawn", name: "plain", message: "x" });
|
||||
assert.deepEqual((plain.details as { tools?: string[] }).tools, [
|
||||
"read",
|
||||
"write",
|
||||
"edit",
|
||||
"bash",
|
||||
"send_file",
|
||||
"artifact",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
// The agent's terminal tool end to end with the fake provider and a real terminal host (skipped without a pi-env
|
||||
// daemon build): code mode scripts drive the session's terminals, which are the owner's (listed, labeled, typed into
|
||||
// by both); guest turns may use it; tmux errors come back as tool errors; the server serves only the session's current worker, on the session's environments, and archiving closes
|
||||
// the session's terminals. Without code mode, `tool_search` loads the tool.
|
||||
// the session's terminals; a subagent's scripts open windows at its own location. Without code mode, `tool_search`
|
||||
// loads the tool.
|
||||
import assert from "node:assert/strict";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { realpathSync, writeFileSync } from "node:fs";
|
||||
import { mkdirSync, realpathSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { test } from "node:test";
|
||||
import { setTimeout as sleep } from "node:timers/promises";
|
||||
|
|
@ -210,6 +211,30 @@ test(
|
|||
await expectChord(whenLive(owner.hold(AgentTerminals, {}, BACKGROUND_CONTEXT)), "forbidden");
|
||||
});
|
||||
|
||||
await t.test("a subagent's script opens windows at the subagent's own location", async () => {
|
||||
const own = join(workspace, "own");
|
||||
mkdirSync(own);
|
||||
const opener =
|
||||
'return await tools.terminal({ command: "new-window", args: ["-n", "sub", "-w", "/^in-sub$/", "echo in-s\'\'ub"] });';
|
||||
const spawn = { action: "spawn", name: "own", message: `codemode ${opener}`, cwd: "own" };
|
||||
const spawned = await call(`subagent ${JSON.stringify(spawn)}`);
|
||||
assert.equal(spawned.isError, false, text(spawned));
|
||||
// Its answer is reported to the main conversation; that turn ends before the next script.
|
||||
await until(
|
||||
chat.view,
|
||||
(value) =>
|
||||
JSON.stringify(value?.entries ?? []).includes("[subagent own answered") &&
|
||||
!(value?.docs["pi.live"] as { run?: unknown })?.run,
|
||||
);
|
||||
const { windows } = (await script('return await tools.terminal({ command: "list-windows" });')) as {
|
||||
windows: { name?: string; opener: string; location: string }[];
|
||||
};
|
||||
assert.deepEqual(
|
||||
windows.filter((window) => window.name === "sub").map(({ opener, location }) => ({ opener, location })),
|
||||
[{ opener: "agent", location: own }],
|
||||
);
|
||||
});
|
||||
|
||||
await t.test("archiving the session closes its terminals", async () => {
|
||||
await sessions.archive({ id: session.id }, BACKGROUND_CONTEXT);
|
||||
const service = (await fixture.server.host.registry(TerminalServices, BACKGROUND_CONTEXT)).value!.service;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue