mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-10-27 19:25:24 +00:00
feat: enable permission-aware subagents
This commit is contained in:
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* mention-clone.ts — start a mentioned agent through a clone of this
|
||||
* conversation, without putting anything in the chat.
|
||||
*
|
||||
* Claude Code routes `@agent-<type>` through the main model: the mention
|
||||
* becomes a `<system-reminder>` appended to the prompt and the model makes the
|
||||
* tool call (see `agentMentionReminder`). That buys the spawned agent a prompt
|
||||
* written with conversation context, and costs a visible turn — the model's
|
||||
* reasoning and its tool block land in the transcript, for a decision the user
|
||||
* already made when they typed the handle.
|
||||
*
|
||||
* So the turn happens somewhere else. The conversation is cloned into a
|
||||
* throwaway in-memory session — same messages, same system prompt, same model —
|
||||
* and that copy takes the turn off-screen. A literal clone: the session's own
|
||||
* entries, projected by pi's own `sessionEntryToContextMessages`, not
|
||||
* `inherit_context`'s text rendering of them.
|
||||
*
|
||||
* Cloned from memory rather than from the session file, which cannot be relied
|
||||
* on: `SessionManager._persist` withholds every write until the first assistant
|
||||
* message lands, so a fork taken before then reads an empty file and throws.
|
||||
* `buildSessionContext()` has no such timing, and is compaction-aware — it walks
|
||||
* the leaf path and substitutes the summary for entries folded into it, so a
|
||||
* long conversation clones as what the main model is actually working from. A
|
||||
* conversation with nothing in it yet clones to nothing in it yet, which is the
|
||||
* correct answer rather than a failure.
|
||||
*
|
||||
* It is also the oldest of the equivalent Pi APIs — `buildContextEntries` on
|
||||
* ReadonlySessionManager and the `sessionEntryToContextMessages` export both
|
||||
* arrived in 0.80.5 — where this one has been exported unchanged from before
|
||||
* the declared peer floor, and is the same code path (`byId` is only an index
|
||||
* cache, so passing it or not cannot change the result). Keeping the floor
|
||||
* honest costs nothing here: see the `compat-floor-pi` job.
|
||||
*
|
||||
* Its `thinkingLevel` is NOT used, and is the one place the newer API would be
|
||||
* better. `getSessionContextSettings` starts at "off" and moves only on an
|
||||
* explicit `thinking_level_change` entry, so a session where nobody ran
|
||||
* `/think` reports "off" rather than the level it is really using. Omitting the
|
||||
* field instead lets `createAgentSession` resolve it from settings, which is
|
||||
* that real level.
|
||||
*
|
||||
* Three details make the spawn belong to the real session rather than the
|
||||
* clone:
|
||||
*
|
||||
* - the clone is handed the *registered* `Agent` tool, whose handler closes
|
||||
* over the main activation, so it spawns top-level: widget, fleet row,
|
||||
* handle, completion notification, all as if the main model had called it;
|
||||
* - that tool is re-bound to the main `ExtensionContext`, because the handler
|
||||
* reads `cwd`, `model` and `sessionManager.getSessionId()` off it to place
|
||||
* the transcript and the `rootSessionId`. The clone's own context would
|
||||
* file both under the throwaway fork;
|
||||
* - it is called with no tool-call id. The clone's turn produces one, but the
|
||||
* real session never issued it, and a `<tool-use-id>` pointing at nothing
|
||||
* is exactly the bug the mention-resume path had to fix;
|
||||
* - and it is forced into the background. A foreground agent returns its
|
||||
* answer as the tool result and is marked `resultConsumed` so no completion
|
||||
* notification is sent — correct when the caller is the real conversation,
|
||||
* silent loss when the caller is a fork about to be discarded. Background
|
||||
* delivery is the only route from a mention back to the main model.
|
||||
*
|
||||
* The clone gets one tool and one job. It cannot read, write or run anything —
|
||||
* an invisible turn with the full toolset could do invisible work.
|
||||
*/
|
||||
|
||||
import type { Model } from "@earendil-works/pi-ai";
|
||||
import {
|
||||
buildSessionContext,
|
||||
createAgentSession,
|
||||
DefaultResourceLoader,
|
||||
type ExtensionContext,
|
||||
getAgentDir,
|
||||
SessionManager,
|
||||
type ToolDefinition,
|
||||
} from "@earendil-works/pi-coding-agent";
|
||||
import { runInChildSessionContext } from "./child-context.js";
|
||||
import { agentMentionReminder } from "./mention.js";
|
||||
import type { SubagentType, ThinkingLevel } from "./types.js";
|
||||
|
||||
export interface MentionCloneOptions {
|
||||
/** The MAIN session's context — what the spawn is attributed to, and the
|
||||
* source of both the conversation and the live system prompt. */
|
||||
ctx: ExtensionContext;
|
||||
/** Agent type the handle resolved to. */
|
||||
type: SubagentType;
|
||||
/** What the user typed after the handle. */
|
||||
message: string;
|
||||
/** The registered `Agent` tool, reused so the spawn is an ordinary one. */
|
||||
agentTool: ToolDefinition;
|
||||
}
|
||||
|
||||
export interface MentionCloneResult {
|
||||
/** True once the clone actually called `Agent`. */
|
||||
spawned: boolean;
|
||||
/** Why not, when it didn't. Absent on success. */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fork the conversation, let the copy make the tool call, throw the copy away.
|
||||
* Never rejects: a clone that cannot run is reported so the caller can fall
|
||||
* back to starting the agent directly.
|
||||
*/
|
||||
export async function runMentionClone(opts: MentionCloneOptions): Promise<MentionCloneResult> {
|
||||
const { ctx, type, message, agentTool } = opts;
|
||||
|
||||
let spawned = false;
|
||||
const cloneAgentTool: ToolDefinition = {
|
||||
...agentTool,
|
||||
execute: (_cloneToolCallId, params, signal, onUpdate, _cloneCtx) => {
|
||||
// One spawn per mention. The clone has a single tool and every reason to
|
||||
// stop after using it, but a model that decides to "also" launch a second
|
||||
// agent would do it where nobody can see and nobody asked.
|
||||
if (spawned) {
|
||||
return Promise.resolve({
|
||||
content: [{ type: "text" as const, text: "Already started an agent for this mention. Stop here." }],
|
||||
details: undefined,
|
||||
isError: true,
|
||||
});
|
||||
}
|
||||
spawned = true;
|
||||
// undefined tool-call id + the main ctx: see the header. Background is
|
||||
// forced rather than left to the clone: `run_in_background` defaults to
|
||||
// false, and a foreground agent answers through its TOOL RESULT — which
|
||||
// here is delivered into a session that is disposed moments later, so the
|
||||
// agent would run, appear in the widget and the fleet, and reach nobody.
|
||||
return agentTool.execute(
|
||||
undefined as never,
|
||||
{ ...(params as Record<string, unknown>), run_in_background: true } as typeof params,
|
||||
signal,
|
||||
onUpdate,
|
||||
ctx,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
let session: Awaited<ReturnType<typeof createAgentSession>>["session"] | undefined;
|
||||
try {
|
||||
// Pi 0.80.8 moved createAgentSession from modelRegistry to modelRuntime;
|
||||
// agent-runner.ts carries the same shim for the same reason — pass both so
|
||||
// the clone keeps the parent's providers across the supported range.
|
||||
const parentModelRuntime = (ctx.modelRegistry as unknown as { runtime?: unknown }).runtime;
|
||||
// The conversation as the main session resolves it: compaction applied,
|
||||
// branch summaries substituted.
|
||||
const conversation = buildSessionContext(
|
||||
ctx.sessionManager.getEntries(),
|
||||
ctx.sessionManager.getLeafId(),
|
||||
);
|
||||
// Pi 0.82.0 added this; below it the field is absent and the clone takes
|
||||
// the settings level instead, which is what a session that never ran
|
||||
// `/think` is on anyway. Same shim shape as `modelRuntime` below.
|
||||
const thinkingLevel = (ctx as { thinkingLevel?: ThinkingLevel }).thinkingLevel;
|
||||
// This off-screen clone must expose only its synthetic Agent tool. Loading
|
||||
// host/project extensions would let active-tool owners such as Tool Search
|
||||
// replace the clone's allowlist during session_start, and would also run
|
||||
// unrelated extension lifecycle in a session the user never sees.
|
||||
const resourceLoader = new DefaultResourceLoader({
|
||||
cwd: ctx.cwd,
|
||||
agentDir: getAgentDir(),
|
||||
noExtensions: true,
|
||||
noSkills: true,
|
||||
noPromptTemplates: true,
|
||||
noThemes: true,
|
||||
});
|
||||
const created = await runInChildSessionContext(() =>
|
||||
createAgentSession({
|
||||
cwd: ctx.cwd,
|
||||
// Nothing about the copy is worth persisting, and an in-memory manager
|
||||
// is also what keeps the real session untouched.
|
||||
sessionManager: SessionManager.inMemory(ctx.cwd),
|
||||
resourceLoader,
|
||||
model: ctx.model as Model<never> | undefined,
|
||||
...(thinkingLevel && { thinkingLevel }),
|
||||
modelRegistry: ctx.modelRegistry,
|
||||
...(parentModelRuntime !== undefined && { modelRuntime: parentModelRuntime as never }),
|
||||
// An allowlist naming exactly the clone's own tool. NOT `noTools:
|
||||
// "all"`, whose doc comment ("start with no tools enabled") reads like
|
||||
// it spares custom tools and does not: it resolves to an EMPTY
|
||||
// allowlist, and `isAllowedTool` then drops every tool from the
|
||||
// registry — the custom one included. The clone would be prompted with
|
||||
// nothing to call, answer in prose, and every mention would fall
|
||||
// through to the direct start with a warning. Same idiom as
|
||||
// agent-runner's `tools: sessionTools` beside its nested `customTools`.
|
||||
tools: [cloneAgentTool.name],
|
||||
customTools: [cloneAgentTool],
|
||||
} as Parameters<typeof createAgentSession>[0]),
|
||||
);
|
||||
session = created.session;
|
||||
|
||||
// The clone rebuilds a system prompt from cwd and agentDir, which is close
|
||||
// but not the live one — extensions contribute to it per turn. Copy the
|
||||
// real thing, so the copy reasons under the instructions the user's model
|
||||
// is actually working under.
|
||||
const systemPrompt = ctx.getSystemPrompt?.();
|
||||
if (systemPrompt) session.agent.state.systemPrompt = systemPrompt;
|
||||
|
||||
// The conversation itself. Pushed rather than assigned so the array the
|
||||
// session was built around stays the one it goes on using.
|
||||
session.agent.state.messages.push(...conversation.messages);
|
||||
|
||||
// User text first, reminder after — the order Claude Code's attachment
|
||||
// renderer produces, where the reminder trails the message it is about.
|
||||
await session.prompt(`${message}\n\n${agentMentionReminder(type)}`);
|
||||
} catch (err) {
|
||||
return { spawned, error: err instanceof Error ? err.message : String(err) };
|
||||
} finally {
|
||||
session?.dispose?.();
|
||||
}
|
||||
|
||||
return spawned
|
||||
? { spawned: true }
|
||||
: { spawned: false, error: "the conversation clone did not start it" };
|
||||
}
|
||||
Reference in New Issue
Block a user