mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-06 23:44:36 +00:00
feat(coding-agent): codemode and MCP
This adds codemode and MCP support to pi. Codemode (packages/codemode) runs model-written JavaScript in a QuickJS wasm VM inside a worker. From there the script calls pi's tools as async functions, uses a per-session store, and can read the model catalog and run classifiers. The codemode tool in coding-agent is compatible with Codex's exec tool, and codemode.mode works like Codex's tool modes (on or only). Large results are written to a JSON temp file. MCP (packages/mcp) is a standalone client for stdio and streamable HTTP servers, with OAuth. The core only gets general mechanisms; codemode, tool_search and MCP are built-in extensions that use them. Other extensions can replace these built-ins. Tools declare an exposure: direct, model-only, codemode, deferred or hidden. prepareLoadout() lets an orchestrating tool change which tool declarations the model sees. ctx.executeTool() runs nested calls through validation and the tool_call/tool_result hooks. Those calls emit events with parentToolCallId and are recorded as bounded nestedCalls on the parent result, which compaction and HTML export use. Tools can also return structuredContent with an outputSchema, and can report errors with isError instead of throwing. MCP servers come from mcp.json (global, or per project after the project is trusted) or from pi.registerMcpServer(). They are managed with /mcp and pi mcp list|login|logout, and their runtimes load on first use. Closes #10040
This commit is contained in:
parent
b485fa3128
commit
8562bcf66a
143 changed files with 17620 additions and 225 deletions
|
|
@ -15,15 +15,21 @@
|
|||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { basename, dirname } from "node:path";
|
||||
import type {
|
||||
Agent,
|
||||
AgentContext,
|
||||
AgentEvent,
|
||||
AgentMessage,
|
||||
AgentState,
|
||||
AgentTool,
|
||||
PrepareNextTurnContext,
|
||||
ThinkingLevel,
|
||||
import {
|
||||
type AfterToolCallContext,
|
||||
type AfterToolCallResult,
|
||||
type Agent,
|
||||
type AgentContext,
|
||||
type AgentEvent,
|
||||
type AgentMessage,
|
||||
type AgentState,
|
||||
type AgentTool,
|
||||
type AgentToolCallOutcome,
|
||||
type BeforeToolCallContext,
|
||||
type BeforeToolCallResult,
|
||||
type PrepareNextTurnContext,
|
||||
runToolCall,
|
||||
type ThinkingLevel,
|
||||
} from "@earendil-works/pi-agent-core";
|
||||
import { contentText, getCurrentSystemMessage, retryDelayMs } from "@earendil-works/pi-ai";
|
||||
import type {
|
||||
|
|
@ -78,6 +84,7 @@ import {
|
|||
type AgentActivityOutcome,
|
||||
type BoundaryContextPreview,
|
||||
type ContextUsage,
|
||||
type ExecuteToolOptions,
|
||||
type ExtensionCommandContextActions,
|
||||
type ExtensionErrorListener,
|
||||
type ExtensionMode,
|
||||
|
|
@ -98,7 +105,9 @@ import {
|
|||
type ToolExecutionEndEvent,
|
||||
type ToolExecutionStartEvent,
|
||||
type ToolExecutionUpdateEvent,
|
||||
type ToolExposure,
|
||||
type ToolInfo,
|
||||
type ToolLoadout,
|
||||
type TreePreparation,
|
||||
type TurnStartEvent,
|
||||
wrapRegisteredTools,
|
||||
|
|
@ -107,6 +116,7 @@ import { emitSessionShutdownEvent } from "./extensions/runner.ts";
|
|||
import { type BashExecutionMessage, type CustomMessage, convertToLlm } from "./messages.ts";
|
||||
import { ModelRegistry } from "./model-registry.ts";
|
||||
import type { ModelRuntime } from "./model-runtime.ts";
|
||||
import { NestedToolCallRunner } from "./nested-tool-calls.ts";
|
||||
import { expandPromptTemplate, type PromptTemplate } from "./prompt-templates.ts";
|
||||
import type { ResourceExtensionPaths, ResourceLoader } from "./resource-loader.ts";
|
||||
import { exportSessionToJsonl } from "./session-export.ts";
|
||||
|
|
@ -169,9 +179,16 @@ export function parseSkillBlock(text: string): ParsedSkillBlock | null {
|
|||
};
|
||||
}
|
||||
|
||||
/** Tool execution events of calls a tool made through `ctx.executeTool()` carry `parentToolCallId`. */
|
||||
type WithParentToolCallId<E> = E extends {
|
||||
type: "tool_execution_start" | "tool_execution_update" | "tool_execution_end";
|
||||
}
|
||||
? E & { parentToolCallId?: string }
|
||||
: E;
|
||||
|
||||
/** Session-specific events that extend the core AgentEvent */
|
||||
export type AgentSessionEvent =
|
||||
| Exclude<AgentEvent, { type: "agent_end" }>
|
||||
| WithParentToolCallId<Exclude<AgentEvent, { type: "agent_end" }>>
|
||||
| {
|
||||
type: "agent_end";
|
||||
messages: AgentMessage[];
|
||||
|
|
@ -418,6 +435,10 @@ export class AgentSession {
|
|||
|
||||
// Tool registry for extension getTools/setTools
|
||||
private _toolRegistry: Map<string, AgentTool> = new Map();
|
||||
/** Created on the first `ctx.executeTool()` call. */
|
||||
private _nestedToolCalls: NestedToolCallRunner | undefined;
|
||||
/** Declared tools whose declarations requests leave out, from `prepareLoadout` hooks. */
|
||||
private _hiddenDeclarations: ReadonlySet<string> = new Set();
|
||||
private _toolDefinitions: Map<string, ToolDefinitionEntry> = new Map();
|
||||
private _toolPromptSnippets: Map<string, string> = new Map();
|
||||
private _toolPromptGuidelines: Map<string, string[]> = new Map();
|
||||
|
|
@ -453,6 +474,7 @@ export class AgentSession {
|
|||
this._installAgentNextTurnRefresh();
|
||||
this._installAgentRequestProjection();
|
||||
this._installAgentBoundaryHooks();
|
||||
this._installHiddenDeclarationsProjection();
|
||||
this._installAgentForcedPromptProjection();
|
||||
|
||||
this._buildRuntime({
|
||||
|
|
@ -587,61 +609,117 @@ export class AgentSession {
|
|||
* happens here instead of in wrappers.
|
||||
*/
|
||||
private _installAgentToolHooks(): void {
|
||||
this.agent.beforeToolCall = async ({ toolCall, args }) => {
|
||||
const runner = this._extensionRunner;
|
||||
if (!runner.hasHandlers("tool_call")) {
|
||||
return undefined;
|
||||
}
|
||||
this.agent.beforeToolCall = (context) => this._beforeToolCall(context);
|
||||
this.agent.afterToolCall = (context) => this._afterToolCall(context);
|
||||
}
|
||||
|
||||
try {
|
||||
return await runner.emitToolCall({
|
||||
type: "tool_call",
|
||||
/** `tool_call` handlers. `parentToolCallId` is set for calls another tool made. */
|
||||
private async _beforeToolCall(
|
||||
{ toolCall, args }: BeforeToolCallContext,
|
||||
parentToolCallId?: string,
|
||||
): Promise<BeforeToolCallResult | undefined> {
|
||||
const runner = this._extensionRunner;
|
||||
if (!runner.hasHandlers("tool_call")) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
return await runner.emitToolCall({
|
||||
type: "tool_call",
|
||||
toolName: toolCall.name,
|
||||
toolCallId: toolCall.id,
|
||||
...(parentToolCallId ? { parentToolCallId } : {}),
|
||||
input: args as Record<string, unknown>,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof Error) {
|
||||
throw err;
|
||||
}
|
||||
throw new Error(`Extension failed, blocking execution: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** `tool_result` handlers and image normalization. `parentToolCallId` is set for calls another tool made. */
|
||||
private async _afterToolCall(
|
||||
{ toolCall, args, result, isError }: AfterToolCallContext,
|
||||
parentToolCallId?: string,
|
||||
): Promise<AfterToolCallResult | undefined> {
|
||||
const runner = this._extensionRunner;
|
||||
const hookResult = runner.hasHandlers("tool_result")
|
||||
? await runner.emitToolResult({
|
||||
type: "tool_result",
|
||||
toolName: toolCall.name,
|
||||
toolCallId: toolCall.id,
|
||||
...(parentToolCallId ? { parentToolCallId } : {}),
|
||||
input: args as Record<string, unknown>,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof Error) {
|
||||
throw err;
|
||||
content: result.content,
|
||||
details: result.details,
|
||||
...(result.structuredContent === undefined ? {} : { structuredContent: result.structuredContent }),
|
||||
isError,
|
||||
usage: result.usage,
|
||||
})
|
||||
: undefined;
|
||||
|
||||
const content = hookResult?.content ?? result.content ?? [];
|
||||
// Runs after the extension hook so images injected or replaced by extensions are normalized too.
|
||||
const resizeOptions = this._limitsModel()?.inputLimits?.images?.resize;
|
||||
const normalizedContent = await normalizeToolResultImages(content, {
|
||||
autoResizeImages: this.settingsManager.getImageAutoResize(),
|
||||
...(resizeOptions ? { resizeOptions } : {}),
|
||||
});
|
||||
|
||||
if (!hookResult && normalizedContent === content) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// The hook result already dropped structured content that replaced content no longer matches.
|
||||
return {
|
||||
content: normalizedContent,
|
||||
details: hookResult?.details,
|
||||
structuredContent: hookResult ? hookResult.structuredContent : result.structuredContent,
|
||||
isError: hookResult?.isError ?? isError,
|
||||
usage: hookResult?.usage,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a call that the tool call `parentToolCallId` made through `ctx.executeTool()`. It goes
|
||||
* through the agent's tool pipeline with the session's hooks, against the callable tools.
|
||||
*/
|
||||
private async _executeNestedToolCall(
|
||||
parentToolCallId: string,
|
||||
name: string,
|
||||
args: unknown,
|
||||
options: ExecuteToolOptions,
|
||||
): Promise<AgentToolCallOutcome> {
|
||||
this._nestedToolCalls ??= new NestedToolCallRunner({
|
||||
getTools: () => this._getCallableTools(),
|
||||
isSequential: () => this.agent.toolExecution === "sequential",
|
||||
runToolCall: (toolCall, parentId, signal, onUpdate) => {
|
||||
const assistantMessage = this._findLastAssistantMessage();
|
||||
if (!assistantMessage) {
|
||||
return Promise.resolve({
|
||||
toolCall,
|
||||
result: { content: [{ type: "text", text: "No assistant message issued this call" }], details: {} },
|
||||
isError: true,
|
||||
});
|
||||
}
|
||||
throw new Error(`Extension failed, blocking execution: ${String(err)}`);
|
||||
}
|
||||
};
|
||||
|
||||
this.agent.afterToolCall = async ({ toolCall, args, result, isError }) => {
|
||||
const runner = this._extensionRunner;
|
||||
const hookResult = runner.hasHandlers("tool_result")
|
||||
? await runner.emitToolResult({
|
||||
type: "tool_result",
|
||||
toolName: toolCall.name,
|
||||
toolCallId: toolCall.id,
|
||||
input: args as Record<string, unknown>,
|
||||
content: result.content,
|
||||
details: result.details,
|
||||
isError,
|
||||
usage: result.usage,
|
||||
})
|
||||
: undefined;
|
||||
|
||||
const content = hookResult?.content ?? result.content ?? [];
|
||||
// Runs after the extension hook so images injected or replaced by extensions are normalized too.
|
||||
const resizeOptions = this._limitsModel()?.inputLimits?.images?.resize;
|
||||
const normalizedContent = await normalizeToolResultImages(content, {
|
||||
autoResizeImages: this.settingsManager.getImageAutoResize(),
|
||||
...(resizeOptions ? { resizeOptions } : {}),
|
||||
});
|
||||
|
||||
if (!hookResult && normalizedContent === content) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
content: normalizedContent,
|
||||
details: hookResult?.details,
|
||||
isError: hookResult?.isError ?? isError,
|
||||
usage: hookResult?.usage,
|
||||
};
|
||||
};
|
||||
return runToolCall(toolCall, {
|
||||
tools: this._getCallableTools(),
|
||||
assistantMessage,
|
||||
context: { messages: this.agent.state.messages, tools: this.agent.state.tools },
|
||||
beforeToolCall: (context) => this._beforeToolCall(context, parentId),
|
||||
afterToolCall: (context) => this._afterToolCall(context, parentId),
|
||||
signal,
|
||||
onUpdate,
|
||||
});
|
||||
},
|
||||
emit: async (event) => {
|
||||
await this._extensionRunner.emit(event);
|
||||
this._emit(event);
|
||||
},
|
||||
});
|
||||
return this._nestedToolCalls.execute(parentToolCallId, name, args, options);
|
||||
}
|
||||
|
||||
/** Whether `projection`, the current session projection, exceeds the compaction threshold of `model`. */
|
||||
|
|
@ -981,6 +1059,15 @@ export class AgentSession {
|
|||
|
||||
/** Internal handler for agent events - shared by subscribe and reconnect */
|
||||
private _handleAgentEvent = async (event: AgentEvent): Promise<void> => {
|
||||
// Record the calls a tool made through ctx.executeTool() on its result message.
|
||||
if (this._nestedToolCalls) {
|
||||
if (event.type === "message_start" && event.message.role === "toolResult") {
|
||||
const nestedCalls = this._nestedToolCalls.takeRecord(event.message.toolCallId);
|
||||
if (nestedCalls) event.message.nestedCalls = nestedCalls;
|
||||
} else if (event.type === "agent_end") {
|
||||
this._nestedToolCalls.clear();
|
||||
}
|
||||
}
|
||||
// When a user message starts, check if it's from either queue and remove it BEFORE emitting
|
||||
// This ensures the UI sees the updated queue state
|
||||
if (event.type === "message_start" && event.message.role === "user") {
|
||||
|
|
@ -1343,13 +1430,18 @@ export class AgentSession {
|
|||
}
|
||||
|
||||
/**
|
||||
* Get the names of currently active tools.
|
||||
* Returns the names of tools currently set on the agent.
|
||||
* Get the names of currently active tools, which are the tools declared to the model.
|
||||
* Tools with `codemode` or `deferred` exposure are callable from other tools without being active.
|
||||
*/
|
||||
getActiveToolNames(): string[] {
|
||||
return this.agent.state.tools.map((t) => t.name);
|
||||
}
|
||||
|
||||
/** Get the names of the tools that tools can call through `ctx.executeTool()`. */
|
||||
getCallableToolNames(): string[] {
|
||||
return this._getCallableTools().map((t) => t.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all configured tools with name, description, parameter schema, prompt guidelines, and source metadata.
|
||||
*/
|
||||
|
|
@ -1359,6 +1451,9 @@ export class AgentSession {
|
|||
description: definition.description,
|
||||
parameters: definition.parameters,
|
||||
promptGuidelines: definition.promptGuidelines,
|
||||
exposure: this._getToolExposure(definition.name),
|
||||
...(definition.namespace ? { namespace: definition.namespace } : {}),
|
||||
...(definition.annotations ? { annotations: { ...definition.annotations } } : {}),
|
||||
sourceInfo,
|
||||
}));
|
||||
}
|
||||
|
|
@ -1369,22 +1464,80 @@ export class AgentSession {
|
|||
|
||||
/**
|
||||
* Set active tools by name.
|
||||
* Only tools in the registry can be enabled. Unknown tool names are ignored.
|
||||
* Only tools in the registry can be enabled. Unknown and hidden tool names are ignored.
|
||||
* Also rebuilds the system prompt to reflect the new tool set.
|
||||
* Changes take effect on the next agent turn.
|
||||
*/
|
||||
setActiveToolsByName(toolNames: string[]): void {
|
||||
const tools: AgentTool[] = [];
|
||||
const validToolNames: string[] = [];
|
||||
for (const name of toolNames) {
|
||||
const tools = this._applyToolLoadout(toolNames);
|
||||
this._rebuildSystemPrompt(tools.map((tool) => tool.name));
|
||||
}
|
||||
|
||||
private _getToolExposure(name: string): ToolExposure {
|
||||
return this._toolDefinitions.get(name)?.definition.exposure ?? "direct";
|
||||
}
|
||||
|
||||
/**
|
||||
* Tools callable through `ctx.executeTool()`: the active `direct` tools and every registered
|
||||
* `codemode` or `deferred` tool.
|
||||
*/
|
||||
private _getCallableTools(active: ReadonlySet<string> = new Set(this.getActiveToolNames())): AgentTool[] {
|
||||
return [...this._toolRegistry.values()].filter((tool) => {
|
||||
const exposure = this._getToolExposure(tool.name);
|
||||
return exposure === "codemode" || exposure === "deferred" || (exposure === "direct" && active.has(tool.name));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the agent's tools for the given active tool names and return them. The active tools are
|
||||
* the registered, non-hidden ones; they are declared to the model. Active tools with a
|
||||
* `prepareLoadout` hook can change the declared descriptions and hide declarations from
|
||||
* requests (see {@link _installHiddenDeclarationsProjection}).
|
||||
*/
|
||||
private _applyToolLoadout(toolNames: string[]): AgentTool[] {
|
||||
const tools = [...new Set(toolNames)].flatMap((name) => {
|
||||
const tool = this._toolRegistry.get(name);
|
||||
if (tool) {
|
||||
tools.push(tool);
|
||||
validToolNames.push(name);
|
||||
return tool && this._getToolExposure(name) !== "hidden" ? [tool] : [];
|
||||
});
|
||||
const hooks = tools.flatMap((tool) => {
|
||||
const entry = this._toolDefinitions.get(tool.name);
|
||||
return entry?.definition.prepareLoadout ? [entry] : [];
|
||||
});
|
||||
const hidden = new Set<string>();
|
||||
let declared = tools;
|
||||
if (hooks.length > 0) {
|
||||
const loadout: ToolLoadout = {
|
||||
declared: tools,
|
||||
callable: this._getCallableTools(new Set(tools.map((tool) => tool.name))),
|
||||
registered: [...this._toolRegistry.values()],
|
||||
getExposure: (name) => this._getToolExposure(name),
|
||||
getNamespace: (name) => this._toolDefinitions.get(name)?.definition.namespace,
|
||||
};
|
||||
const descriptions = new Map<string, string>();
|
||||
for (const { definition, sourceInfo } of hooks) {
|
||||
try {
|
||||
const changes = definition.prepareLoadout?.(loadout);
|
||||
for (const [name, description] of Object.entries(changes?.descriptions ?? {})) {
|
||||
descriptions.set(name, description);
|
||||
}
|
||||
for (const name of changes?.hiddenDeclarations ?? []) hidden.add(name);
|
||||
} catch (error) {
|
||||
this._extensionRunner.emitError({
|
||||
extensionPath: sourceInfo.path,
|
||||
event: "prepare_loadout",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
declared = tools.map((tool) => {
|
||||
const description = descriptions.get(tool.name);
|
||||
return description === undefined ? tool : { ...tool, description };
|
||||
});
|
||||
}
|
||||
this.agent.state.tools = tools;
|
||||
this._rebuildSystemPrompt(validToolNames);
|
||||
this._hiddenDeclarations = hidden;
|
||||
this.agent.state.tools = declared;
|
||||
return declared;
|
||||
}
|
||||
|
||||
/** Whether compaction or branch summarization is currently running */
|
||||
|
|
@ -1505,11 +1658,7 @@ export class AgentSession {
|
|||
options: NormalizedBuildSystemPromptOptions,
|
||||
messages: AgentMessage[] = this.agent.state.messages,
|
||||
): SystemMessage | undefined {
|
||||
options.selectedTools = [...new Set(options.selectedTools)].filter((name) => this._toolRegistry.has(name));
|
||||
this.agent.state.tools = options.selectedTools.flatMap((name) => {
|
||||
const tool = this._toolRegistry.get(name);
|
||||
return tool ? [tool] : [];
|
||||
});
|
||||
options.selectedTools = this._applyToolLoadout(options.selectedTools).map((tool) => tool.name);
|
||||
const sections = diffSystemPromptSections(
|
||||
getCurrentSystemMessage(messages)?.sections ?? {},
|
||||
buildSystemPromptSections(options),
|
||||
|
|
@ -1527,6 +1676,31 @@ export class AgentSession {
|
|||
* collapse into one head holding the forced text and the current tools. Runs after the
|
||||
* `context` extension handlers.
|
||||
*/
|
||||
/**
|
||||
* Remove the declarations that `prepareLoadout` hooks hide from every request. The whole
|
||||
* transcript is filtered with the current set, so the projected declarations stay consistent
|
||||
* across requests and only change when the loadout does.
|
||||
*/
|
||||
private _installHiddenDeclarationsProjection(): void {
|
||||
const previousTransformContext = this.agent.transformContext;
|
||||
this.agent.transformContext = async (messages, signal) => {
|
||||
const transformed = previousTransformContext ? await previousTransformContext(messages, signal) : messages;
|
||||
const hidden = this._hiddenDeclarations;
|
||||
if (hidden.size === 0) return transformed;
|
||||
return transformed.map((message) => {
|
||||
if (message.role !== "system" || (!message.toolsAdded && !message.toolsRemoved)) return message;
|
||||
const { toolsAdded, toolsRemoved, ...rest } = message;
|
||||
const added = toolsAdded?.filter((tool) => !hidden.has(tool.name)) ?? [];
|
||||
const removed = toolsRemoved?.filter((tool) => !hidden.has(tool.name)) ?? [];
|
||||
return {
|
||||
...rest,
|
||||
...(added.length > 0 ? { toolsAdded: added } : {}),
|
||||
...(removed.length > 0 ? { toolsRemoved: removed } : {}),
|
||||
};
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
private _installAgentForcedPromptProjection(): void {
|
||||
const previousTransformContext = this.agent.transformContext;
|
||||
this.agent.transformContext = async (messages, signal) => {
|
||||
|
|
@ -1544,18 +1718,15 @@ export class AgentSession {
|
|||
};
|
||||
}
|
||||
|
||||
/** Restore the active tool loadout declared by the session transcript, if it declares one. */
|
||||
/**
|
||||
* Restore the active tool loadout declared by the session transcript, if it declares one.
|
||||
* Tools reachable only from other tools are never declared, but they do not depend on the active
|
||||
* set, so the transcript's declarations are the whole loadout.
|
||||
*/
|
||||
private _restoreToolsFromTranscript(): void {
|
||||
const current = getCurrentSystemMessage(this.sessionManager.buildSessionContext().messages);
|
||||
if (!current) return;
|
||||
const toolNames = (current.toolsAdded ?? [])
|
||||
.map((tool) => tool.name)
|
||||
.filter((name) => this._toolRegistry.has(name));
|
||||
this.agent.state.tools = toolNames.flatMap((name) => {
|
||||
const registered = this._toolRegistry.get(name);
|
||||
return registered ? [registered] : [];
|
||||
});
|
||||
this._rebuildSystemPrompt(toolNames);
|
||||
this.setActiveToolsByName((current.toolsAdded ?? []).map((tool) => tool.name));
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
|
|
@ -3017,6 +3188,7 @@ export class AgentSession {
|
|||
|
||||
this._applyExtensionBindings(this._extensionRunner);
|
||||
await this._extensionRunner.emit(this._sessionStartEvent);
|
||||
this._extensionRunner.reportUnhandledMcpServers();
|
||||
await this.extendResourcesFromExtensions(this._sessionStartEvent.reason === "reload" ? "reload" : "startup");
|
||||
}
|
||||
|
||||
|
|
@ -3160,6 +3332,7 @@ export class AgentSession {
|
|||
},
|
||||
getActiveTools: () => this.getActiveToolNames(),
|
||||
getAllTools: () => this.getAllTools(),
|
||||
getSettings: () => this.settingsManager.getSettings(),
|
||||
setActiveTools: (toolNames) => this.setActiveToolsByName(toolNames),
|
||||
refreshTools: () => this._refreshToolRegistry(),
|
||||
getCommands,
|
||||
|
|
@ -3202,6 +3375,8 @@ export class AgentSession {
|
|||
},
|
||||
getSystemPrompt: () => this.systemPrompt,
|
||||
getSystemPromptOptions: () => this._baseSystemPromptOptions,
|
||||
executeTool: (callerId, name, args, options) => this._executeNestedToolCall(callerId, name, args, options),
|
||||
getCallableTools: () => this._getCallableTools(),
|
||||
},
|
||||
{
|
||||
registerProvider: (name, config) => {
|
||||
|
|
@ -3229,7 +3404,11 @@ export class AgentSession {
|
|||
}
|
||||
|
||||
private _refreshToolRegistry(options?: { activeToolNames?: string[]; includeAllExtensionTools?: boolean }): void {
|
||||
const previousRegistryNames = new Set(this._toolRegistry.keys());
|
||||
// Tools that were already activated on registration. A tool whose exposure changes to
|
||||
// `direct` or `model-only` (for example from `hidden`) is activated like a new tool.
|
||||
const previousActivatedOnRegistration = new Set(
|
||||
[...this._toolRegistry.keys()].filter((name) => this._isActivatedOnRegistration(name)),
|
||||
);
|
||||
const previousActiveToolNames = this.getActiveToolNames();
|
||||
const allowedToolNames = this._allowedToolNames;
|
||||
const excludedToolNames = this._excludedToolNames;
|
||||
|
|
@ -3302,17 +3481,18 @@ export class AgentSession {
|
|||
|
||||
if (allowedToolNames) {
|
||||
for (const toolName of this._toolRegistry.keys()) {
|
||||
if (allowedToolNames.has(toolName)) {
|
||||
// Naming a tool activates it even when it is not active by default.
|
||||
if (allowedToolNames.has(toolName) && this._isDeclarable(toolName)) {
|
||||
nextActiveToolNames.push(toolName);
|
||||
}
|
||||
}
|
||||
} else if (options?.includeAllExtensionTools) {
|
||||
for (const tool of wrappedExtensionTools) {
|
||||
nextActiveToolNames.push(tool.name);
|
||||
if (this._isActivatedOnRegistration(tool.name)) nextActiveToolNames.push(tool.name);
|
||||
}
|
||||
} else if (!options?.activeToolNames) {
|
||||
for (const toolName of this._toolRegistry.keys()) {
|
||||
if (!previousRegistryNames.has(toolName)) {
|
||||
if (!previousActivatedOnRegistration.has(toolName) && this._isActivatedOnRegistration(toolName)) {
|
||||
nextActiveToolNames.push(toolName);
|
||||
}
|
||||
}
|
||||
|
|
@ -3321,6 +3501,17 @@ export class AgentSession {
|
|||
this.setActiveToolsByName([...new Set(nextActiveToolNames)]);
|
||||
}
|
||||
|
||||
/** Whether activating the tool declares it to the model. */
|
||||
private _isDeclarable(name: string): boolean {
|
||||
const exposure = this._getToolExposure(name);
|
||||
return exposure === "direct" || exposure === "model-only";
|
||||
}
|
||||
|
||||
/** Whether registering the tool activates it, which declares it to the model. */
|
||||
private _isActivatedOnRegistration(name: string): boolean {
|
||||
return this._isDeclarable(name) && this._toolDefinitions.get(name)?.definition.defaultActive !== false;
|
||||
}
|
||||
|
||||
private _buildRuntime(options: {
|
||||
activeToolNames?: string[];
|
||||
flagValues?: Map<string, boolean | string>;
|
||||
|
|
@ -3398,6 +3589,7 @@ export class AgentSession {
|
|||
if (hasBindings) {
|
||||
await options?.beforeSessionStart?.();
|
||||
await this._extensionRunner.emit({ type: "session_start", reason: "reload" });
|
||||
this._extensionRunner.reportUnhandledMcpServers();
|
||||
await this.extendResourcesFromExtensions("reload");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,9 +24,15 @@ export function createFileOps(): FileOperations {
|
|||
}
|
||||
|
||||
/**
|
||||
* Extract file operations from tool calls in an assistant message.
|
||||
* Extract file operations from tool calls in an assistant message, or from the nested calls
|
||||
* recorded on a tool result.
|
||||
*/
|
||||
export function extractFileOpsFromMessage(message: AgentMessage, fileOps: FileOperations): void {
|
||||
if (message.role === "toolResult") {
|
||||
// Calls made from codemode scripts are recorded on the script's result.
|
||||
for (const call of message.nestedCalls?.calls ?? []) addFileOp(call.name, call.arguments, fileOps);
|
||||
return;
|
||||
}
|
||||
if (message.role !== "assistant") return;
|
||||
if (!("content" in message) || !Array.isArray(message.content)) return;
|
||||
|
||||
|
|
@ -34,24 +40,23 @@ export function extractFileOpsFromMessage(message: AgentMessage, fileOps: FileOp
|
|||
if (typeof block !== "object" || block === null) continue;
|
||||
if (!("type" in block) || block.type !== "toolCall") continue;
|
||||
if (!("arguments" in block) || !("name" in block)) continue;
|
||||
addFileOp(block.name, block.arguments as Record<string, unknown> | undefined, fileOps);
|
||||
}
|
||||
}
|
||||
|
||||
const args = block.arguments as Record<string, unknown> | undefined;
|
||||
if (!args) continue;
|
||||
|
||||
const path = typeof args.path === "string" ? args.path : undefined;
|
||||
if (!path) continue;
|
||||
|
||||
switch (block.name) {
|
||||
case "read":
|
||||
fileOps.read.add(path);
|
||||
break;
|
||||
case "write":
|
||||
fileOps.written.add(path);
|
||||
break;
|
||||
case "edit":
|
||||
fileOps.edited.add(path);
|
||||
break;
|
||||
}
|
||||
function addFileOp(toolName: string, args: Record<string, unknown> | undefined, fileOps: FileOperations): void {
|
||||
const path = typeof args?.path === "string" ? args.path : undefined;
|
||||
if (!path) return;
|
||||
switch (toolName) {
|
||||
case "read":
|
||||
fileOps.read.add(path);
|
||||
break;
|
||||
case "write":
|
||||
fileOps.written.add(path);
|
||||
break;
|
||||
case "edit":
|
||||
fileOps.edited.add(path);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -930,6 +930,21 @@
|
|||
'</div>';
|
||||
};
|
||||
|
||||
// Calls this tool made to other tools (for example from a codemode script), recorded without results.
|
||||
const renderNestedCalls = () => {
|
||||
const nested = result?.nestedCalls;
|
||||
if (!nested || !Array.isArray(nested.calls) || nested.calls.length === 0) return '';
|
||||
const icons = { ok: '✓', error: '✗', unfinished: '…' };
|
||||
const lines = nested.calls.map(c => {
|
||||
const args = c.arguments ? JSON.stringify(c.arguments) : `[arguments omitted, ${c.argumentsBytes} bytes]`;
|
||||
const duration = c.durationMs !== undefined ? ` ${c.durationMs}ms` : '';
|
||||
const error = c.error ? `\n ${c.error.split('\n').join('\n ')}` : '';
|
||||
return `${icons[c.status] || '?'} ${c.name} ${args}${duration}${error}`;
|
||||
});
|
||||
const title = `Nested calls: ${nested.calls.length}${nested.complete ? '' : ' (incomplete record)'}`;
|
||||
return formatExpandableOutput([title, ...lines].join('\n'), 1);
|
||||
};
|
||||
|
||||
const toolDomId = `tool-call-${escapeHtml(call.id)}`;
|
||||
let html = `<div class="tool-execution ${statusClass}" id="${toolDomId}">`;
|
||||
const args = call.arguments || {};
|
||||
|
|
@ -1063,6 +1078,7 @@
|
|||
}
|
||||
}
|
||||
|
||||
html += renderNestedCalls();
|
||||
html += '</div>';
|
||||
return html;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -70,6 +70,7 @@ export type {
|
|||
EntryRenderOptions,
|
||||
ExecOptions,
|
||||
ExecResult,
|
||||
ExecuteToolOptions,
|
||||
Extension,
|
||||
ExtensionActions,
|
||||
// API
|
||||
|
|
@ -88,6 +89,7 @@ export type {
|
|||
// Runtime
|
||||
ExtensionRuntime,
|
||||
ExtensionShortcut,
|
||||
ExtensionToolContext,
|
||||
ExtensionUIContext,
|
||||
ExtensionUIDialogOptions,
|
||||
ExtensionVirtualModel,
|
||||
|
|
@ -111,6 +113,7 @@ export type {
|
|||
LsToolResultEvent,
|
||||
MarkdownTransformContext,
|
||||
MarkdownTransformer,
|
||||
McpServersChangeEvent,
|
||||
// Events - Message
|
||||
MessageEndEvent,
|
||||
MessageEndEventResult,
|
||||
|
|
@ -167,6 +170,7 @@ export type {
|
|||
SetThinkingLevelHandler,
|
||||
TerminalInputHandler,
|
||||
ThinkingLevelSelectEvent,
|
||||
ToolAnnotations,
|
||||
// Events - Tool
|
||||
ToolCallEvent,
|
||||
ToolCallEventResult,
|
||||
|
|
@ -178,7 +182,11 @@ export type {
|
|||
ToolExecutionMode,
|
||||
ToolExecutionStartEvent,
|
||||
ToolExecutionUpdateEvent,
|
||||
ToolExposure,
|
||||
ToolInfo,
|
||||
ToolLoadout,
|
||||
ToolLoadoutChanges,
|
||||
ToolNamespace,
|
||||
ToolRenderResultOptions,
|
||||
ToolResultEvent,
|
||||
ToolResultEventResult,
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import { resolvePath } from "../../utils/paths.ts";
|
|||
import { createEventBus, type EventBus } from "../event-bus.ts";
|
||||
import type { ExecOptions } from "../exec.ts";
|
||||
import { execCommand } from "../exec.ts";
|
||||
import { type McpServerConfig, McpServerRegistry, validateMcpServerConfig } from "../mcp-servers.ts";
|
||||
import { readPiManifest } from "../pi-manifest.ts";
|
||||
import { createSyntheticSourceInfo } from "../source-info.ts";
|
||||
import { time } from "../timings.ts";
|
||||
|
|
@ -173,6 +174,7 @@ export function createExtensionRuntime(): ExtensionRuntime {
|
|||
setLabel: notInitialized,
|
||||
getActiveTools: notInitialized,
|
||||
getAllTools: notInitialized,
|
||||
getSettings: notInitialized,
|
||||
setActiveTools: notInitialized,
|
||||
// registerTool() is valid during extension load; refresh is only needed post-bind.
|
||||
refreshTools: () => {},
|
||||
|
|
@ -183,6 +185,7 @@ export function createExtensionRuntime(): ExtensionRuntime {
|
|||
flagValues: new Map(),
|
||||
pendingProviderRegistrations: [],
|
||||
pendingNativeProviderRegistrations: [],
|
||||
mcpServers: new McpServerRegistry(),
|
||||
pendingVirtualModelRegistrations: [],
|
||||
createContext: notInitialized,
|
||||
assertActive,
|
||||
|
|
@ -405,6 +408,11 @@ function createExtensionAPI(
|
|||
return runtime.getAllTools();
|
||||
},
|
||||
|
||||
getSettings() {
|
||||
assertActive();
|
||||
return runtime.getSettings();
|
||||
},
|
||||
|
||||
setActiveTools(toolNames: string[]): void {
|
||||
assertActive();
|
||||
runtime.setActiveTools(toolNames);
|
||||
|
|
@ -445,6 +453,30 @@ function createExtensionAPI(
|
|||
applyRuntimeChange(() => runtime.unregisterProvider(name, extension.path));
|
||||
},
|
||||
|
||||
registerMcpServer(name: string, config: McpServerConfig) {
|
||||
assertActive();
|
||||
const validated = validateMcpServerConfig(name, config);
|
||||
if (typeof validated === "string") {
|
||||
throw new Error(`Invalid MCP server registered by extension "${extension.path}": ${validated}`);
|
||||
}
|
||||
const owner = runtime.mcpServers.get(name)?.extensionPath;
|
||||
if (owner !== undefined && owner !== extension.path) {
|
||||
throw new Error(`MCP server "${name}" is already registered by extension "${owner}"`);
|
||||
}
|
||||
const server = { name, config: structuredClone(validated), extensionPath: extension.path };
|
||||
applyRuntimeChange(() => runtime.mcpServers.register(server));
|
||||
},
|
||||
|
||||
unregisterMcpServer(name: string) {
|
||||
assertActive();
|
||||
applyRuntimeChange(() => runtime.mcpServers.unregister(name, extension.path));
|
||||
},
|
||||
|
||||
getMcpServers() {
|
||||
assertActive();
|
||||
return runtime.mcpServers.list();
|
||||
},
|
||||
|
||||
registerVirtualModel<TState>(model: ExtensionVirtualModel<TState>) {
|
||||
assertActive();
|
||||
// Routing runs after the runner binds, so the context is created per request. The state
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
* Extension runner - executes extensions and manages their lifecycle.
|
||||
*/
|
||||
|
||||
import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
||||
import type { AgentMessage, AgentTool } from "@earendil-works/pi-agent-core";
|
||||
import {
|
||||
getCurrentSystemMessage,
|
||||
type ImageContent,
|
||||
|
|
@ -41,6 +41,7 @@ import type {
|
|||
ContextUsage,
|
||||
ContextWithSystemEvent,
|
||||
EntryRenderer,
|
||||
ExecuteToolOptions,
|
||||
Extension,
|
||||
ExtensionActions,
|
||||
ExtensionCommandContext,
|
||||
|
|
@ -53,6 +54,7 @@ import type {
|
|||
ExtensionMode,
|
||||
ExtensionRuntime,
|
||||
ExtensionShortcut,
|
||||
ExtensionToolContext,
|
||||
ExtensionUIContext,
|
||||
InputEvent,
|
||||
InputEventResult,
|
||||
|
|
@ -373,6 +375,10 @@ export class ExtensionRunner {
|
|||
private getSystemPromptFn: () => string = () => "";
|
||||
private getSystemPromptOptionsFn: () => BuildSystemPromptOptions = () =>
|
||||
normalizeBuildSystemPromptOptions({ cwd: this.cwd });
|
||||
private executeToolFn: ExtensionContextActions["executeTool"];
|
||||
private getCallableToolsFn: () => readonly AgentTool[] = () => [];
|
||||
/** Registered MCP servers already reported as unhandled. */
|
||||
private readonly reportedMcpServers = new Set<string>();
|
||||
private newSessionHandler: NewSessionHandler = async () => ({ cancelled: false });
|
||||
private forkHandler: ForkHandler = async () => ({ cancelled: false });
|
||||
private navigateTreeHandler: NavigateTreeHandler = async () => ({ cancelled: false });
|
||||
|
|
@ -420,6 +426,7 @@ export class ExtensionRunner {
|
|||
this.runtime.setLabel = actions.setLabel;
|
||||
this.runtime.getActiveTools = actions.getActiveTools;
|
||||
this.runtime.getAllTools = actions.getAllTools;
|
||||
this.runtime.getSettings = actions.getSettings;
|
||||
this.runtime.setActiveTools = actions.setActiveTools;
|
||||
this.runtime.refreshTools = actions.refreshTools;
|
||||
this.runtime.getCommands = actions.getCommands;
|
||||
|
|
@ -442,6 +449,15 @@ export class ExtensionRunner {
|
|||
this.getSystemPromptFn = contextActions.getSystemPrompt;
|
||||
this.getSystemPromptOptionsFn =
|
||||
contextActions.getSystemPromptOptions ?? (() => normalizeBuildSystemPromptOptions({ cwd: this.cwd }));
|
||||
this.executeToolFn = contextActions.executeTool;
|
||||
this.getCallableToolsFn = contextActions.getCallableTools ?? (() => []);
|
||||
|
||||
// Servers registered from now on reach the extension that connects them right away. Servers
|
||||
// registered during loading are read on session_start.
|
||||
this.runtime.mcpServers.setChangeListener(() => {
|
||||
void this.emit({ type: "mcp_servers_change", servers: this.runtime.mcpServers.list() });
|
||||
this.reportUnhandledMcpServers();
|
||||
});
|
||||
|
||||
// Flush provider registrations queued during extension loading
|
||||
for (const { name, config, extensionPath } of this.runtime.pendingProviderRegistrations) {
|
||||
|
|
@ -728,6 +744,23 @@ export class ExtensionRunner {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Report registered MCP servers when no extension handles `mcp_servers_change`, which means
|
||||
* nothing connects them (for example when another MCP extension replaced the built-in one).
|
||||
*/
|
||||
reportUnhandledMcpServers(): void {
|
||||
if (this.hasHandlers("mcp_servers_change")) return;
|
||||
for (const server of this.runtime.mcpServers.list()) {
|
||||
if (this.reportedMcpServers.has(server.name)) continue;
|
||||
this.reportedMcpServers.add(server.name);
|
||||
this.emitError({
|
||||
extensionPath: server.extensionPath,
|
||||
event: "register_mcp_server",
|
||||
error: `MCP server "${server.name}" is registered, but no loaded extension connects MCP servers; another extension may have replaced the built-in MCP support`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
hasHandlers(eventType: string): boolean {
|
||||
for (const ext of this.extensions) {
|
||||
const handlers = ext.handlers.get(eventType);
|
||||
|
|
@ -912,6 +945,39 @@ export class ExtensionRunner {
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the context for executing the tool call `toolCallId`: the extension context plus
|
||||
* `tools` and `executeTool()`. `signal` is the default signal of nested calls.
|
||||
*/
|
||||
createToolContext(toolCallId: string, signal: AbortSignal | undefined): ExtensionToolContext {
|
||||
const runner = this;
|
||||
// createContext() returns a fresh object, so adding properties does not affect other contexts.
|
||||
return Object.defineProperties(this.createContext() as ExtensionToolContext, {
|
||||
tools: {
|
||||
get() {
|
||||
runner.assertActive();
|
||||
return runner.getCallableToolsFn();
|
||||
},
|
||||
},
|
||||
executeTool: {
|
||||
value: async (name: string, args: unknown, options: ExecuteToolOptions = {}) => {
|
||||
runner.assertActive();
|
||||
if (!runner.executeToolFn) {
|
||||
return {
|
||||
toolCall: { type: "toolCall", id: `${toolCallId}/0`, name, arguments: {} },
|
||||
result: {
|
||||
content: [{ type: "text", text: "Nested tool calls are not available in this context" }],
|
||||
details: {},
|
||||
},
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
return runner.executeToolFn(toolCallId, name, args, { ...options, signal: options.signal ?? signal });
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
createCommandContext(): ExtensionCommandContext {
|
||||
// Use property descriptors instead of object spread so the guarded getters from
|
||||
// createContext() stay lazy. A spread would eagerly read them once and freeze the
|
||||
|
|
@ -1118,12 +1184,18 @@ export class ExtensionRunner {
|
|||
|
||||
if (handlerResult.content !== undefined) {
|
||||
currentEvent.content = handlerResult.content;
|
||||
// Structured content that is not replaced along with the content may no longer match it.
|
||||
if (handlerResult.structuredContent === undefined) delete currentEvent.structuredContent;
|
||||
modified = true;
|
||||
}
|
||||
if (handlerResult.details !== undefined) {
|
||||
currentEvent.details = handlerResult.details;
|
||||
modified = true;
|
||||
}
|
||||
if (handlerResult.structuredContent !== undefined) {
|
||||
currentEvent.structuredContent = handlerResult.structuredContent;
|
||||
modified = true;
|
||||
}
|
||||
if (handlerResult.isError !== undefined) {
|
||||
currentEvent.isError = handlerResult.isError;
|
||||
modified = true;
|
||||
|
|
@ -1152,6 +1224,7 @@ export class ExtensionRunner {
|
|||
return {
|
||||
content: currentEvent.content,
|
||||
details: currentEvent.details,
|
||||
structuredContent: currentEvent.structuredContent,
|
||||
isError: currentEvent.isError,
|
||||
usage: currentEvent.usage,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@
|
|||
|
||||
import type {
|
||||
AgentMessage,
|
||||
AgentTool,
|
||||
AgentToolCallOutcome,
|
||||
AgentToolResult,
|
||||
AgentToolUpdateCallback,
|
||||
ThinkingLevel,
|
||||
|
|
@ -24,6 +26,7 @@ import type {
|
|||
ConstrainedSamplingConfig,
|
||||
ImageApi,
|
||||
ImageContent,
|
||||
JsonValue,
|
||||
Message,
|
||||
Model,
|
||||
OAuthCredentials,
|
||||
|
|
@ -60,6 +63,7 @@ import type { EventBus } from "../event-bus.ts";
|
|||
import type { ExecOptions, ExecResult } from "../exec.ts";
|
||||
import type { ReadonlyFooterDataProvider } from "../footer-data-provider.ts";
|
||||
import type { KeybindingsManager } from "../keybindings.ts";
|
||||
import type { McpServerConfig, McpServerRegistry, RegisteredMcpServer } from "../mcp-servers.ts";
|
||||
import type { CustomMessage } from "../messages.ts";
|
||||
import type { ModelRegistry } from "../model-registry.ts";
|
||||
import type { ScopedModel } from "../model-resolver.ts";
|
||||
|
|
@ -73,6 +77,7 @@ import type {
|
|||
SessionEntry,
|
||||
SessionManager,
|
||||
} from "../session-manager.ts";
|
||||
import type { Settings } from "../settings-manager.ts";
|
||||
import type { SlashCommandInfo } from "../slash-commands.ts";
|
||||
import type { SourceInfo } from "../source-info.ts";
|
||||
import type { BuildSystemPromptOptions, NormalizedBuildSystemPromptOptions } from "../system-prompt.ts";
|
||||
|
|
@ -359,6 +364,36 @@ export interface ExtensionContext {
|
|||
getSystemPrompt(): string;
|
||||
}
|
||||
|
||||
/** Options for {@link ExtensionToolContext.executeTool}. */
|
||||
export interface ExecuteToolOptions {
|
||||
/** Defaults to the calling tool's signal. */
|
||||
signal?: AbortSignal;
|
||||
/** Receives partial results of the nested tool, in addition to `tool_execution_update` events. */
|
||||
onUpdate?: AgentToolUpdateCallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Context passed to tool `execute()` in a session: the extension context plus `executeTool()`
|
||||
* for running other tools through the same validation, hooks, and permission checks as
|
||||
* model-issued calls.
|
||||
*
|
||||
* A tool wrapped with `wrapToolDefinition()` without a context factory, such as a built-in tool
|
||||
* created with `createBashTool()` and run in a plain `Agent` or called directly, gets no context.
|
||||
*/
|
||||
export interface ExtensionToolContext extends ExtensionContext {
|
||||
/** Tools {@link executeTool} can call. */
|
||||
readonly tools: readonly AgentTool[];
|
||||
/**
|
||||
* Run another tool. The call gets the id `<calling id>/<n>`, and the `tool_call`, `tool_result`,
|
||||
* and `tool_execution_*` events carry `parentToolCallId`. It does not appear in the transcript;
|
||||
* a bounded record of it is kept as `nestedCalls` on the calling tool's result message.
|
||||
*
|
||||
* Never rejects for tool failures: unknown tools, validation errors, blocked calls, and thrown
|
||||
* errors come back as `isError: true`.
|
||||
*/
|
||||
executeTool(name: string, args: unknown, options?: ExecuteToolOptions): Promise<AgentToolCallOutcome>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extended context for command handlers.
|
||||
* Includes session control methods only safe in user-initiated commands.
|
||||
|
|
@ -456,6 +491,69 @@ export interface ToolRenderContext<TState = any, TArgs = any> {
|
|||
isError: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* How the model reaches a tool. "Callable" means callable from other tools through
|
||||
* `ctx.executeTool()`, as the `codemode` tool does.
|
||||
*
|
||||
* - `direct`: declared to the model while active, and callable while active.
|
||||
* - `model-only`: declared to the model while active, never callable. Use it for orchestrating or
|
||||
* interactive tools.
|
||||
* - `codemode`: callable whenever registered. Not declared to the model unless explicitly
|
||||
* activated. Codemode tools list it in their description.
|
||||
* - `deferred`: like `codemode`, but codemode tools do not list it; tool search can find it.
|
||||
* - `hidden`: registered but unreachable. Activating it has no effect.
|
||||
*
|
||||
* `direct` and `model-only` tools are activated when they are registered; the others are not.
|
||||
* The active tool set (`getActiveTools`/`setActiveTools`) is the set declared to the model.
|
||||
*/
|
||||
export type ToolExposure = "direct" | "model-only" | "codemode" | "deferred" | "hidden";
|
||||
|
||||
/**
|
||||
* Hints about what a tool does, with the meaning of MCP tool annotations. They come from the tool's
|
||||
* author and are not verified; permission extensions can use them to decide which calls to confirm.
|
||||
*/
|
||||
export interface ToolAnnotations {
|
||||
/** The tool does not modify its environment. */
|
||||
readOnlyHint?: boolean;
|
||||
/** The tool may delete or overwrite data, rather than only add to it. Meaningful when not read-only. */
|
||||
destructiveHint?: boolean;
|
||||
/** Repeating a call with the same arguments has no further effect. Meaningful when not read-only. */
|
||||
idempotentHint?: boolean;
|
||||
/** The tool reaches an open world of external entities, such as the web, rather than a closed domain. */
|
||||
openWorldHint?: boolean;
|
||||
}
|
||||
|
||||
/** A group of related tools, such as the tools of one MCP server. Codemode tools list them together. */
|
||||
export interface ToolNamespace {
|
||||
/** For example `mcp__docs`. */
|
||||
name: string;
|
||||
/** Shown once above the group's tools. */
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/** The tools of a session as {@link ToolDefinition.prepareLoadout} sees them. */
|
||||
export interface ToolLoadout {
|
||||
/** Tools declared to the model (the active tools), in order, with their original descriptions. */
|
||||
readonly declared: readonly AgentTool[];
|
||||
/** Tools callable through `ctx.executeTool()`. */
|
||||
readonly callable: readonly AgentTool[];
|
||||
/** Every registered tool. */
|
||||
readonly registered: readonly AgentTool[];
|
||||
getExposure(name: string): ToolExposure;
|
||||
getNamespace(name: string): ToolNamespace | undefined;
|
||||
}
|
||||
|
||||
/** Changes {@link ToolDefinition.prepareLoadout} makes to what the model sees. */
|
||||
export interface ToolLoadoutChanges {
|
||||
/** Model-facing descriptions of declared tools, by tool name. */
|
||||
descriptions?: Readonly<Record<string, string>>;
|
||||
/**
|
||||
* Declared tools whose declarations requests leave out. They stay active and callable, and the
|
||||
* transcript still declares them, so the active set survives `/tree` and resume.
|
||||
*/
|
||||
hiddenDeclarations?: readonly string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool definition for registerTool().
|
||||
*/
|
||||
|
|
@ -480,6 +578,37 @@ export interface ToolDefinition<TParams extends TSchema = TSchema, TDetails = un
|
|||
/** Optional compatibility shim to prepare raw tool call arguments before schema validation. Must return an object conforming to TParams. */
|
||||
prepareArguments?: (args: unknown) => Static<TParams>;
|
||||
|
||||
/**
|
||||
* JSON Schema of `structuredContent` in successful results. Tools that declare it should always
|
||||
* set `structuredContent`; codemode scripts then receive it instead of the text content.
|
||||
*/
|
||||
outputSchema?: TSchema;
|
||||
|
||||
/**
|
||||
* How the model reaches the tool. Default: `"direct"`. See {@link ToolExposure}.
|
||||
*/
|
||||
exposure?: ToolExposure;
|
||||
|
||||
/** Group the tool belongs to, for example its MCP server. */
|
||||
namespace?: ToolNamespace;
|
||||
|
||||
/** Hints about what the tool does, for example from an MCP server. */
|
||||
annotations?: ToolAnnotations;
|
||||
|
||||
/**
|
||||
* Whether registering the tool activates it. Default: `true` for `direct` and `model-only` tools;
|
||||
* other exposures are never activated on registration. A tool with `defaultActive: false` is
|
||||
* activated by naming it in `--tools` or the `defaultTools` setting, or with `setActiveTools()`.
|
||||
*/
|
||||
defaultActive?: boolean;
|
||||
|
||||
/**
|
||||
* Adjust how the loadout is presented to the model while this tool is active. Called whenever
|
||||
* the active tools change. Tools that orchestrate other tools use it, for example to list the
|
||||
* callable tools in their own description.
|
||||
*/
|
||||
prepareLoadout?: (loadout: ToolLoadout) => ToolLoadoutChanges | undefined;
|
||||
|
||||
/**
|
||||
* Per-tool execution mode override.
|
||||
* - "sequential": this tool must execute one at a time with other tool calls.
|
||||
|
|
@ -495,7 +624,7 @@ export interface ToolDefinition<TParams extends TSchema = TSchema, TDetails = un
|
|||
params: Static<TParams>,
|
||||
signal: AbortSignal | undefined,
|
||||
onUpdate: AgentToolUpdateCallback<TDetails> | undefined,
|
||||
ctx: ExtensionContext,
|
||||
ctx: ExtensionToolContext,
|
||||
): Promise<AgentToolResult<TDetails>>;
|
||||
|
||||
/** Custom rendering for tool call display */
|
||||
|
|
@ -567,6 +696,18 @@ export interface ResourcesDiscoverResult {
|
|||
themePaths?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Fired when an extension registers or unregisters an MCP server after the extensions are bound
|
||||
* (see {@link ExtensionAPI.registerMcpServer}). Servers registered while extensions load are read
|
||||
* with `pi.getMcpServers()` on `session_start`. Handling this event marks an extension as the one
|
||||
* that connects registered servers.
|
||||
*/
|
||||
export interface McpServersChangeEvent {
|
||||
type: "mcp_servers_change";
|
||||
/** Every registered server after the change. */
|
||||
servers: RegisteredMcpServer[];
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Session Events
|
||||
// ============================================================================
|
||||
|
|
@ -900,6 +1041,8 @@ export interface ToolExecutionStartEvent {
|
|||
toolCallId: string;
|
||||
toolName: string;
|
||||
args: any;
|
||||
/** Set when another tool (for example a codemode script) made this call. */
|
||||
parentToolCallId?: string;
|
||||
}
|
||||
|
||||
/** Fired during tool execution with partial/streaming output */
|
||||
|
|
@ -909,6 +1052,8 @@ export interface ToolExecutionUpdateEvent {
|
|||
toolName: string;
|
||||
args: any;
|
||||
partialResult: any;
|
||||
/** Set when another tool (for example a codemode script) made this call. */
|
||||
parentToolCallId?: string;
|
||||
}
|
||||
|
||||
/** Fired when a tool finishes executing */
|
||||
|
|
@ -918,6 +1063,8 @@ export interface ToolExecutionEndEvent {
|
|||
toolName: string;
|
||||
result: any;
|
||||
isError: boolean;
|
||||
/** Set when another tool (for example a codemode script) made this call. */
|
||||
parentToolCallId?: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
|
@ -988,7 +1135,14 @@ export type InputEventResult =
|
|||
|
||||
interface ToolCallEventBase {
|
||||
type: "tool_call";
|
||||
/**
|
||||
* The call's id. For calls another tool made (with `parentToolCallId` set), pi assigns
|
||||
* `<parent id>/<n>`; such ids never appear as tool calls or tool results in the transcript, only
|
||||
* in the parent result's `nestedCalls` record.
|
||||
*/
|
||||
toolCallId: string;
|
||||
/** Set when another tool (for example a codemode script) issued this call. */
|
||||
parentToolCallId?: string;
|
||||
}
|
||||
|
||||
export interface BashToolCallEvent extends ToolCallEventBase {
|
||||
|
|
@ -1055,9 +1209,17 @@ export type ToolCallEvent =
|
|||
|
||||
interface ToolResultEventBase {
|
||||
type: "tool_result";
|
||||
/** The call's id; `<parent id>/<n>` for nested calls, see `ToolCallEvent`. */
|
||||
toolCallId: string;
|
||||
/** Set when another tool (for example a codemode script) issued this call. */
|
||||
parentToolCallId?: string;
|
||||
input: Record<string, unknown>;
|
||||
content: (TextContent | ImageContent)[];
|
||||
/**
|
||||
* Machine-readable result for tools that declare an `outputSchema`. Handlers that redact
|
||||
* `content` should also replace this; replacing `content` alone drops it.
|
||||
*/
|
||||
structuredContent?: JsonValue;
|
||||
isError: boolean;
|
||||
/** Usage from the tool execution itself, if available. */
|
||||
usage?: Usage;
|
||||
|
|
@ -1186,6 +1348,7 @@ export function isToolCallEventType(toolName: string, event: ToolCallEvent): boo
|
|||
export type ExtensionEvent =
|
||||
| ProjectTrustEvent
|
||||
| ResourcesDiscoverEvent
|
||||
| McpServersChangeEvent
|
||||
| SessionEvent
|
||||
| ContextEvent
|
||||
| ContextWithSystemEvent
|
||||
|
|
@ -1255,9 +1418,15 @@ export type UserBashEventResult =
|
|||
result: BashResult;
|
||||
};
|
||||
|
||||
/**
|
||||
* Changes a `tool_result` handler makes. Omitted fields stay as they are, except that replacing
|
||||
* `content` without returning `structuredContent` drops the structured content, because it may no
|
||||
* longer match. Return it along with `content` to keep it.
|
||||
*/
|
||||
export interface ToolResultEventResult {
|
||||
content?: (TextContent | ImageContent)[];
|
||||
details?: unknown;
|
||||
structuredContent?: JsonValue;
|
||||
isError?: boolean;
|
||||
usage?: Usage;
|
||||
}
|
||||
|
|
@ -1390,6 +1559,7 @@ export interface ExtensionAPI {
|
|||
on(event: "session_compact", handler: ExtensionHandler<SessionCompactEvent>): () => void;
|
||||
on(event: "session_compact_failed", handler: ExtensionHandler<SessionCompactFailedEvent>): () => void;
|
||||
on(event: "session_shutdown", handler: ExtensionHandler<SessionShutdownEvent>): () => void;
|
||||
on(event: "mcp_servers_change", handler: ExtensionHandler<McpServersChangeEvent>): () => void;
|
||||
on(
|
||||
event: "session_before_tree",
|
||||
handler: ExtensionHandler<SessionBeforeTreeEvent, SessionBeforeTreeResult>,
|
||||
|
|
@ -1532,13 +1702,19 @@ export interface ExtensionAPI {
|
|||
/** Execute a shell command. */
|
||||
exec(command: string, args: string[], options?: ExecOptions): Promise<ExecResult>;
|
||||
|
||||
/** Get the list of currently active tool names. */
|
||||
/** Get the names of the active tools, which are the tools declared to the model. */
|
||||
getActiveTools(): string[];
|
||||
|
||||
/** Get all configured tools with parameter schema, prompt guidelines, and source metadata. */
|
||||
/** Get all configured tools with parameter schema, prompt guidelines, exposure, and source metadata. */
|
||||
getAllTools(): ToolInfo[];
|
||||
|
||||
/** Set the active tools by name. */
|
||||
/** Get a copy of the effective settings (global and project settings merged, with overrides). */
|
||||
getSettings(): Settings;
|
||||
|
||||
/**
|
||||
* Set the active tools by name. Unknown and `hidden` tools are ignored. Tools with `codemode` or
|
||||
* `deferred` exposure stay callable from codemode scripts whether active or not.
|
||||
*/
|
||||
setActiveTools(toolNames: string[]): void;
|
||||
|
||||
/** Get available slash commands in the current session. */
|
||||
|
|
@ -1637,6 +1813,32 @@ export interface ExtensionAPI {
|
|||
*/
|
||||
unregisterProvider(name: string): void;
|
||||
|
||||
// =========================================================================
|
||||
// MCP Servers
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Register an MCP server for this session, with the same config as an `mcpServers` entry in
|
||||
* `mcp.json`. The server connects next to the configured servers: on `session_start` when
|
||||
* registered during extension load, right away when registered later. Registering a name again
|
||||
* replaces the extension's earlier registration.
|
||||
*
|
||||
* The registration is not saved; register again on every load. A server of the same name in
|
||||
* `mcp.json` takes precedence. Throws for invalid configs and for names another extension
|
||||
* registered. When no loaded extension handles MCP servers (for example because another MCP
|
||||
* extension replaced the built-in one), the registration is reported as an extension error.
|
||||
*
|
||||
* @example
|
||||
* pi.registerMcpServer("jira", { url: "https://mcp.example.com/jira" });
|
||||
*/
|
||||
registerMcpServer(name: string, config: McpServerConfig): void;
|
||||
|
||||
/** Remove an MCP server this extension registered and close its connection. */
|
||||
unregisterMcpServer(name: string): void;
|
||||
|
||||
/** Every MCP server registered by extensions. For extensions that connect MCP servers. */
|
||||
getMcpServers(): RegisteredMcpServer[];
|
||||
|
||||
/**
|
||||
* Register a virtual model: a selectable catalog entry that routes each request to a physical
|
||||
* model. The selection (`ctx.model`, `model_change` entries) names the virtual model; assistant
|
||||
|
|
@ -1790,6 +1992,14 @@ export type InlineExtension =
|
|||
factory: ExtensionFactory;
|
||||
/** Omit this extension from the startup Extensions list. */
|
||||
hidden?: boolean;
|
||||
/**
|
||||
* Leave this extension out when another extension registers a tool, command, or flag with a
|
||||
* name it registers during loading, instead of reporting a conflict. The CLI's built-in MCP,
|
||||
* codemode, and tool search extensions use it, so for example an MCP extension that registers
|
||||
* `/mcp` replaces the built-in MCP support. The factory still runs, so it should only register
|
||||
* tools, commands, flags, and event handlers.
|
||||
*/
|
||||
replaceable?: boolean;
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
|
|
@ -1838,11 +2048,16 @@ export type GetActiveToolsHandler = () => string[];
|
|||
|
||||
/** Tool info with name, description, parameter schema, prompt guidelines, and source metadata. */
|
||||
export type ToolInfo = Pick<ToolDefinition, "name" | "description" | "parameters" | "promptGuidelines"> & {
|
||||
exposure: ToolExposure;
|
||||
namespace?: ToolNamespace;
|
||||
annotations?: ToolAnnotations;
|
||||
sourceInfo: SourceInfo;
|
||||
};
|
||||
|
||||
export type GetAllToolsHandler = () => ToolInfo[];
|
||||
|
||||
export type GetSettingsHandler = () => Settings;
|
||||
|
||||
export type GetCommandsHandler = () => SlashCommandInfo[];
|
||||
|
||||
export type SetActiveToolsHandler = (toolNames: string[]) => void;
|
||||
|
|
@ -1886,6 +2101,8 @@ export interface ExtensionRuntimeState {
|
|||
registerProvider: (name: string, config: ProviderConfig, extensionPath?: string) => void;
|
||||
registerNativeProvider: (provider: Provider, extensionPath?: string) => void;
|
||||
unregisterProvider: (name: string, extensionPath?: string) => void;
|
||||
/** Servers registered with `pi.registerMcpServer()`. */
|
||||
mcpServers: McpServerRegistry;
|
||||
registerVirtualModel: (definition: VirtualModelDefinition, extensionPath?: string) => void;
|
||||
unregisterVirtualModel: (provider: string, id: string) => void;
|
||||
}
|
||||
|
|
@ -1903,6 +2120,7 @@ export interface ExtensionActions {
|
|||
setLabel: SetLabelHandler;
|
||||
getActiveTools: GetActiveToolsHandler;
|
||||
getAllTools: GetAllToolsHandler;
|
||||
getSettings: GetSettingsHandler;
|
||||
setActiveTools: SetActiveToolsHandler;
|
||||
refreshTools: RefreshToolsHandler;
|
||||
getCommands: GetCommandsHandler;
|
||||
|
|
@ -1928,6 +2146,15 @@ export interface ExtensionContextActions {
|
|||
compact: (options?: CompactOptions) => void;
|
||||
getSystemPrompt: () => string;
|
||||
getSystemPromptOptions?: () => BuildSystemPromptOptions;
|
||||
/** Backs `ExtensionToolContext.executeTool()`. Without it, nested calls fail. */
|
||||
executeTool?: (
|
||||
callerId: string,
|
||||
name: string,
|
||||
args: unknown,
|
||||
options: ExecuteToolOptions,
|
||||
) => Promise<AgentToolCallOutcome>;
|
||||
/** Backs `ExtensionToolContext.tools`. */
|
||||
getCallableTools?: () => readonly AgentTool[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -1967,6 +2194,8 @@ export interface Extension {
|
|||
path: string;
|
||||
resolvedPath: string;
|
||||
hidden?: boolean;
|
||||
/** See {@link InlineExtension}. */
|
||||
replaceable?: boolean;
|
||||
sourceInfo: SourceInfo;
|
||||
handlers: Map<string, HandlerFn[]>;
|
||||
tools: Map<string, RegisteredTool>;
|
||||
|
|
|
|||
|
|
@ -12,15 +12,17 @@ import type { RegisteredTool } from "./types.ts";
|
|||
|
||||
/**
|
||||
* Wrap a RegisteredTool into an AgentTool.
|
||||
* Uses the runner's createContext() for consistent context across tools and event handlers.
|
||||
* Uses the runner's createToolContext() for consistent context across tools and event handlers.
|
||||
*/
|
||||
export function wrapRegisteredTool(registeredTool: RegisteredTool, runner: ExtensionRunner): AgentTool {
|
||||
return wrapToolDefinition(registeredTool.definition, () => runner.createContext());
|
||||
return wrapToolDefinition(registeredTool.definition, (toolCallId, signal) =>
|
||||
runner.createToolContext(toolCallId, signal),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap all registered tools into AgentTools.
|
||||
* Uses the runner's createContext() for consistent context across tools and event handlers.
|
||||
* Uses the runner's createToolContext() for consistent context across tools and event handlers.
|
||||
*/
|
||||
export function wrapRegisteredTools(registeredTools: RegisteredTool[], runner: ExtensionRunner): AgentTool[] {
|
||||
return registeredTools.map((tool) => wrapRegisteredTool(tool, runner));
|
||||
|
|
|
|||
237
packages/coding-agent/src/core/mcp-servers.ts
Normal file
237
packages/coding-agent/src/core/mcp-servers.ts
Normal file
|
|
@ -0,0 +1,237 @@
|
|||
/**
|
||||
* MCP server configuration and the servers extensions register with `pi.registerMcpServer()`.
|
||||
*
|
||||
* The core only validates and stores registrations. The MCP extension (built in, or another
|
||||
* extension that handles `mcp_servers_change`) connects them next to the servers from `mcp.json`.
|
||||
*/
|
||||
|
||||
/**
|
||||
* - `codemode`: tools are callable from codemode scripts and listed in its description, but not
|
||||
* declared to the model.
|
||||
* - `codemode-deferred`: like `codemode`, but not listed in the codemode description. Scripts find
|
||||
* them with `searchTools()`.
|
||||
* - `deferred`: not declared to the model until the `tool_search` tool loads them; the model then
|
||||
* calls them directly. Does not need codemode.
|
||||
* - `direct`: tools are declared to the model like any other tool (and callable from codemode).
|
||||
* - `hidden`: tools are registered but unreachable.
|
||||
*/
|
||||
export type McpExposure = "codemode" | "codemode-deferred" | "deferred" | "direct" | "hidden";
|
||||
|
||||
const MCP_EXPOSURES: readonly string[] = [
|
||||
"codemode",
|
||||
"codemode-deferred",
|
||||
"deferred",
|
||||
"direct",
|
||||
"hidden",
|
||||
] satisfies McpExposure[];
|
||||
|
||||
interface McpServerConfigBase {
|
||||
/** Default: `codemode`. */
|
||||
exposure?: McpExposure;
|
||||
/**
|
||||
* Exposure of single tools, overriding `exposure`. Keys are tool names as the server offers them,
|
||||
* or patterns where `*` matches any characters. An exact name wins over patterns; among patterns
|
||||
* the first match in the object wins. `hidden` removes tools, so `"exposure": "hidden"` with
|
||||
* overrides for a few tools exposes only those.
|
||||
*/
|
||||
toolExposure?: Record<string, McpExposure>;
|
||||
/** Set to false to keep the entry without connecting. Default: true. */
|
||||
enabled?: boolean;
|
||||
/** Per-request timeout in seconds. Progress notifications from the server reset it. Default: 60. */
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
export interface McpStdioServerConfig extends McpServerConfigBase {
|
||||
type?: "stdio";
|
||||
command: string;
|
||||
args?: string[];
|
||||
/** Values may reference environment variables (`${NAME}`) or commands (`!cmd`). */
|
||||
env?: Record<string, string>;
|
||||
/** Relative paths resolve against the session working directory. */
|
||||
cwd?: string;
|
||||
}
|
||||
|
||||
/** OAuth client settings for servers that do not support dynamic client registration. */
|
||||
export interface McpOAuthConfig {
|
||||
/** Pre-registered client id. Without it, pi registers a client with the authorization server. */
|
||||
clientId?: string;
|
||||
/** May reference environment variables (`${NAME}`) or commands (`!cmd`). */
|
||||
clientSecret?: string;
|
||||
/**
|
||||
* Port of the loopback callback server, for clients registered with a fixed redirect URI. Without
|
||||
* `callbackUrl`, the redirect URI is `http://127.0.0.1:<port>/callback`.
|
||||
*/
|
||||
callbackPort?: number;
|
||||
/**
|
||||
* Redirect URI registered for `clientId`, for example `http://localhost:8080/oauth/callback`. It must
|
||||
* be an `http` URI on `localhost`, `127.0.0.1`, or `[::1]`. Without a port, the callback server
|
||||
* listens on `callbackPort` or a free port, which is added to the URI (RFC 8252).
|
||||
*/
|
||||
callbackUrl?: string;
|
||||
/** Scopes to request, separated by spaces. Default: the scopes the server advertises. */
|
||||
scope?: string;
|
||||
}
|
||||
|
||||
const LOOPBACK_HOSTS = ["localhost", "127.0.0.1", "[::1]"];
|
||||
|
||||
/** Whether a redirect URI can be served by pi's loopback callback server. */
|
||||
export function isLoopbackRedirectUri(value: string): boolean {
|
||||
if (!URL.canParse(value)) return false;
|
||||
const url = new URL(value);
|
||||
return url.protocol === "http:" && LOOPBACK_HOSTS.includes(url.hostname) && url.search === "" && url.hash === "";
|
||||
}
|
||||
|
||||
export interface McpHttpServerConfig extends McpServerConfigBase {
|
||||
type?: "http";
|
||||
url: string;
|
||||
/** Values may reference environment variables (`${NAME}`) or commands (`!cmd`). */
|
||||
headers?: Record<string, string>;
|
||||
oauth?: McpOAuthConfig;
|
||||
}
|
||||
|
||||
export type McpServerConfig = McpStdioServerConfig | McpHttpServerConfig;
|
||||
|
||||
const SERVER_NAME = /^[A-Za-z0-9_-]+$/;
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isStringRecord(value: unknown): value is Record<string, string> {
|
||||
return isRecord(value) && Object.values(value).every((entry) => typeof entry === "string");
|
||||
}
|
||||
|
||||
function validateOAuth(value: unknown): string | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
if (!isRecord(value)) return "oauth must be an object";
|
||||
if (value.clientId !== undefined && typeof value.clientId !== "string") return "oauth.clientId must be a string";
|
||||
if (value.clientSecret !== undefined && typeof value.clientSecret !== "string") {
|
||||
return "oauth.clientSecret must be a string";
|
||||
}
|
||||
const port = value.callbackPort;
|
||||
if (port !== undefined && (typeof port !== "number" || !Number.isInteger(port) || port < 1 || port > 65535)) {
|
||||
return "oauth.callbackPort must be a port number";
|
||||
}
|
||||
if (value.callbackUrl !== undefined) {
|
||||
if (typeof value.callbackUrl !== "string" || !isLoopbackRedirectUri(value.callbackUrl)) {
|
||||
return "oauth.callbackUrl must be an http URI on localhost, 127.0.0.1, or [::1] without query or fragment";
|
||||
}
|
||||
const urlPort = new URL(value.callbackUrl).port;
|
||||
if (urlPort && port !== undefined && Number(urlPort) !== port) {
|
||||
return "oauth.callbackUrl and oauth.callbackPort name different ports";
|
||||
}
|
||||
}
|
||||
if (value.scope !== undefined && typeof value.scope !== "string") return "oauth.scope must be a string";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function isExposure(value: unknown): value is McpExposure {
|
||||
return typeof value === "string" && MCP_EXPOSURES.includes(value);
|
||||
}
|
||||
|
||||
function toolPatternRegExp(pattern: string): RegExp {
|
||||
const source = pattern
|
||||
.split("*")
|
||||
.map((part) => part.replace(/[.+?^${}()|[\]\\]/g, "\\$&"))
|
||||
.join(".*");
|
||||
return new RegExp(`^${source}$`);
|
||||
}
|
||||
|
||||
/** Exposure of one tool of a server: its `toolExposure` entry, else the server's `exposure`. */
|
||||
export function getMcpToolExposure(config: McpServerConfig, toolName: string): McpExposure {
|
||||
const overrides = config.toolExposure ?? {};
|
||||
const exact = overrides[toolName];
|
||||
if (exact !== undefined) return exact;
|
||||
for (const [pattern, exposure] of Object.entries(overrides)) {
|
||||
if (pattern.includes("*") && toolPatternRegExp(pattern).test(toolName)) return exposure;
|
||||
}
|
||||
return config.exposure ?? "codemode";
|
||||
}
|
||||
|
||||
/** Validate one server entry of the `mcpServers` shape. Returns the config or an error message. */
|
||||
export function validateMcpServerConfig(name: string, value: unknown): McpServerConfig | string {
|
||||
if (!SERVER_NAME.test(name)) return `invalid server name "${name}" (use letters, digits, "_" and "-")`;
|
||||
if (!isRecord(value)) return `server "${name}" must be an object`;
|
||||
const { type, exposure, enabled, timeout, toolExposure } = value;
|
||||
const exposures = MCP_EXPOSURES.map((value) => `"${value}"`).join(", ");
|
||||
if (exposure !== undefined && !isExposure(exposure)) {
|
||||
return `server "${name}": exposure must be one of ${exposures}`;
|
||||
}
|
||||
if (toolExposure !== undefined) {
|
||||
if (!isRecord(toolExposure)) return `server "${name}": toolExposure must map tool names to exposures`;
|
||||
for (const [tool, value] of Object.entries(toolExposure)) {
|
||||
if (!isExposure(value)) return `server "${name}": toolExposure "${tool}" must be one of ${exposures}`;
|
||||
}
|
||||
}
|
||||
if (enabled !== undefined && typeof enabled !== "boolean") return `server "${name}": enabled must be a boolean`;
|
||||
if (timeout !== undefined && (typeof timeout !== "number" || !(timeout > 0))) {
|
||||
return `server "${name}": timeout must be a positive number of seconds`;
|
||||
}
|
||||
if (type === "sse") return `server "${name}": legacy SSE transport is not supported; use the streamable HTTP URL`;
|
||||
|
||||
if (typeof value.url === "string" && (type === undefined || type === "http" || type === "streamable-http")) {
|
||||
if (!URL.canParse(value.url) || !/^https?:$/.test(new URL(value.url).protocol)) {
|
||||
return `server "${name}": url must be an http or https URL`;
|
||||
}
|
||||
if (value.headers !== undefined && !isStringRecord(value.headers)) {
|
||||
return `server "${name}": headers must map names to strings`;
|
||||
}
|
||||
const oauthError = validateOAuth(value.oauth);
|
||||
if (oauthError) return `server "${name}": ${oauthError}`;
|
||||
return value as unknown as McpHttpServerConfig;
|
||||
}
|
||||
if (typeof value.command === "string" && (type === undefined || type === "stdio")) {
|
||||
if (
|
||||
value.args !== undefined &&
|
||||
!(Array.isArray(value.args) && value.args.every((arg) => typeof arg === "string"))
|
||||
) {
|
||||
return `server "${name}": args must be an array of strings`;
|
||||
}
|
||||
if (value.env !== undefined && !isStringRecord(value.env))
|
||||
return `server "${name}": env must map names to strings`;
|
||||
if (value.cwd !== undefined && typeof value.cwd !== "string") return `server "${name}": cwd must be a string`;
|
||||
return value as unknown as McpStdioServerConfig;
|
||||
}
|
||||
return `server "${name}" needs either "command" (stdio) or "url" (streamable HTTP)`;
|
||||
}
|
||||
|
||||
/** A server an extension registered with `pi.registerMcpServer()`. */
|
||||
export interface RegisteredMcpServer {
|
||||
name: string;
|
||||
config: McpServerConfig;
|
||||
/** Path of the extension that registered the server. */
|
||||
extensionPath: string;
|
||||
}
|
||||
|
||||
/** Servers registered by the extensions of one runtime. */
|
||||
export class McpServerRegistry {
|
||||
private readonly servers = new Map<string, RegisteredMcpServer>();
|
||||
private changeListener: (() => void) | undefined;
|
||||
|
||||
/** Register or replace a server. The caller checks ownership. */
|
||||
register(server: RegisteredMcpServer): void {
|
||||
this.servers.set(server.name, server);
|
||||
this.changeListener?.();
|
||||
}
|
||||
|
||||
/** Remove a server registered by `extensionPath`. Servers of other extensions are left alone. */
|
||||
unregister(name: string, extensionPath: string): void {
|
||||
if (this.servers.get(name)?.extensionPath !== extensionPath) return;
|
||||
this.servers.delete(name);
|
||||
this.changeListener?.();
|
||||
}
|
||||
|
||||
get(name: string): RegisteredMcpServer | undefined {
|
||||
return this.servers.get(name);
|
||||
}
|
||||
|
||||
/** Copies of the registered servers, in registration order. */
|
||||
list(): RegisteredMcpServer[] {
|
||||
return [...this.servers.values()].map((server) => ({ ...server, config: structuredClone(server.config) }));
|
||||
}
|
||||
|
||||
/** Called after every change. The runner sets it when it binds, to emit `mcp_servers_change`. */
|
||||
setChangeListener(listener: (() => void) | undefined): void {
|
||||
this.changeListener = listener;
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ import type {
|
|||
Api,
|
||||
AssistantMessage,
|
||||
AssistantMessageEventStream,
|
||||
AuthOperationOptions,
|
||||
AuthResult,
|
||||
ClassifierApi,
|
||||
ClassifierContext,
|
||||
|
|
@ -141,6 +142,28 @@ export class ModelRegistry {
|
|||
return this.runtime.complete(model, context, options);
|
||||
}
|
||||
|
||||
/** Every known model of a type (chat, image, classifier), optionally for one provider. */
|
||||
getModelsOfType<TType extends ModelType>(type: TType, provider?: string): readonly ModelTypeMap[TType][] {
|
||||
return this.runtime.getModelsOfType(type, provider);
|
||||
}
|
||||
|
||||
/** Models of a type whose provider has working credentials. */
|
||||
getAvailableOfType<TType extends ModelType>(
|
||||
type: TType,
|
||||
provider?: string,
|
||||
options?: AuthOperationOptions,
|
||||
): Promise<readonly ModelTypeMap[TType][]> {
|
||||
return this.runtime.getAvailableOfType(type, provider, options);
|
||||
}
|
||||
|
||||
getModelOfType<TType extends ModelType>(
|
||||
type: TType,
|
||||
provider: string,
|
||||
modelId: string,
|
||||
): ModelTypeMap[TType] | undefined {
|
||||
return this.runtime.getModelOfType(type, provider, modelId);
|
||||
}
|
||||
|
||||
/** Classify structured state with request-time authentication. Never rejects. */
|
||||
classify(
|
||||
model: ClassifierModel<ClassifierApi>,
|
||||
|
|
|
|||
238
packages/coding-agent/src/core/nested-tool-calls.ts
Normal file
238
packages/coding-agent/src/core/nested-tool-calls.ts
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
/**
|
||||
* Tool calls that a tool makes while it runs (`ctx.executeTool()`), for example from codemode
|
||||
* scripts. The agent loop does not know about them: the session runs each one through the agent's
|
||||
* tool pipeline (`runToolCall`) with its own hooks, emits `tool_execution_*` events with
|
||||
* `parentToolCallId`, and records the calls on the model-issued call's tool result message.
|
||||
*
|
||||
* Nothing here runs until a tool calls `ctx.executeTool()`.
|
||||
*/
|
||||
|
||||
import type {
|
||||
AgentTool,
|
||||
AgentToolCall,
|
||||
AgentToolCallOutcome,
|
||||
AgentToolResult,
|
||||
AgentToolUpdateCallback,
|
||||
} from "@earendil-works/pi-agent-core";
|
||||
import type { JsonObject, NestedToolCallRecord, NestedToolCalls, TextContent } from "@earendil-works/pi-ai";
|
||||
|
||||
/**
|
||||
* Limits of the nested-call record on a tool result: arguments
|
||||
* over the per-call or total size are omitted, calls beyond the count are dropped, and the record
|
||||
* is marked incomplete when any of that happens.
|
||||
*/
|
||||
export const NESTED_CALL_LIMITS = {
|
||||
maxCalls: 256,
|
||||
maxArgumentBytesPerCall: 8 * 1024,
|
||||
maxArgumentBytesTotal: 32 * 1024,
|
||||
maxErrorChars: 500,
|
||||
} as const;
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
/**
|
||||
* Collects the nested calls of one model-issued tool call, including calls made by nested tools.
|
||||
* The snapshot becomes `nestedCalls` on the tool result message.
|
||||
*/
|
||||
export class NestedCallRecorder {
|
||||
private readonly calls: NestedToolCallRecord[] = [];
|
||||
private readonly startedAt = new Map<NestedToolCallRecord, number>();
|
||||
private complete = true;
|
||||
private argumentBytes = 0;
|
||||
|
||||
/** Record a call as it starts. Returns undefined when the call is dropped. */
|
||||
start(toolCall: AgentToolCall): NestedToolCallRecord | undefined {
|
||||
if (this.calls.length >= NESTED_CALL_LIMITS.maxCalls) {
|
||||
this.complete = false;
|
||||
return undefined;
|
||||
}
|
||||
const record: NestedToolCallRecord = { id: toolCall.id, name: toolCall.name, status: "unfinished" };
|
||||
const json = JSON.stringify(toolCall.arguments ?? {});
|
||||
const bytes = encoder.encode(json).length;
|
||||
if (
|
||||
bytes > NESTED_CALL_LIMITS.maxArgumentBytesPerCall ||
|
||||
this.argumentBytes + bytes > NESTED_CALL_LIMITS.maxArgumentBytesTotal
|
||||
) {
|
||||
record.argumentsBytes = bytes;
|
||||
this.complete = false;
|
||||
} else {
|
||||
record.arguments = JSON.parse(json) as JsonObject;
|
||||
this.argumentBytes += bytes;
|
||||
}
|
||||
this.calls.push(record);
|
||||
this.startedAt.set(record, performance.now());
|
||||
return record;
|
||||
}
|
||||
|
||||
finish(record: NestedToolCallRecord | undefined, isError: boolean, errorText: string): void {
|
||||
if (!record) return;
|
||||
record.status = isError ? "error" : "ok";
|
||||
record.durationMs = Math.round(performance.now() - (this.startedAt.get(record) ?? performance.now()));
|
||||
this.startedAt.delete(record);
|
||||
if (isError && errorText) record.error = errorText.slice(0, NESTED_CALL_LIMITS.maxErrorChars);
|
||||
}
|
||||
|
||||
/** Copy of the record so far, or undefined when no nested call was made. */
|
||||
snapshot(): NestedToolCalls | undefined {
|
||||
if (this.calls.length === 0 && this.complete) return undefined;
|
||||
const calls = this.calls.map((call) => ({ ...call }));
|
||||
return { calls, complete: this.complete && calls.every((call) => call.status !== "unfinished") };
|
||||
}
|
||||
}
|
||||
|
||||
export interface NestedToolCallOptions {
|
||||
/** Defaults to the calling tool's signal. */
|
||||
signal?: AbortSignal;
|
||||
/** Receives partial results of the nested tool, in addition to `tool_execution_update` events. */
|
||||
onUpdate?: AgentToolUpdateCallback;
|
||||
}
|
||||
|
||||
/** `tool_execution_*` events of nested calls. */
|
||||
export type NestedToolExecutionEvent =
|
||||
| { type: "tool_execution_start"; toolCallId: string; toolName: string; args: unknown; parentToolCallId: string }
|
||||
| {
|
||||
type: "tool_execution_update";
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
args: unknown;
|
||||
partialResult: AgentToolResult<unknown>;
|
||||
parentToolCallId: string;
|
||||
}
|
||||
| {
|
||||
type: "tool_execution_end";
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
result: AgentToolResult<unknown>;
|
||||
isError: boolean;
|
||||
parentToolCallId: string;
|
||||
};
|
||||
|
||||
export interface NestedToolCallHost {
|
||||
/** Tools nested calls resolve against. */
|
||||
getTools(): readonly AgentTool[];
|
||||
/** Whether every nested call runs exclusively, as when the agent executes tool calls sequentially. */
|
||||
isSequential(): boolean;
|
||||
/** Run the call through the tool pipeline, with hooks that report `parentToolCallId`. */
|
||||
runToolCall(
|
||||
toolCall: AgentToolCall,
|
||||
parentToolCallId: string,
|
||||
signal: AbortSignal | undefined,
|
||||
onUpdate: (partialResult: AgentToolResult<unknown>) => Promise<void>,
|
||||
): Promise<AgentToolCallOutcome>;
|
||||
emit(event: NestedToolExecutionEvent): Promise<void>;
|
||||
}
|
||||
|
||||
/** Calls below one model-issued call share its recorder. */
|
||||
interface CallScope {
|
||||
recorder: NestedCallRecorder;
|
||||
nextId: number;
|
||||
/** Set inside a call that holds the exclusive queue, so its own nested calls do not wait on it. */
|
||||
holdsQueue: boolean;
|
||||
}
|
||||
|
||||
function textOf(result: AgentToolResult<unknown>): string {
|
||||
return (result.content ?? [])
|
||||
.filter((block): block is TextContent => block.type === "text")
|
||||
.map((block) => block.text)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
export class NestedToolCallRunner {
|
||||
private readonly host: NestedToolCallHost;
|
||||
/** Scopes by the id of the calling tool call. */
|
||||
private readonly scopes = new Map<string, CallScope>();
|
||||
/** Serializes nested calls that must not run concurrently. */
|
||||
private queueTail: Promise<void> = Promise.resolve();
|
||||
|
||||
constructor(host: NestedToolCallHost) {
|
||||
this.host = host;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `name` on behalf of the call `callerId`. The nested call gets the id `<callerId>/<n>`.
|
||||
* Never rejects for tool failures: they come back as `isError: true`.
|
||||
*/
|
||||
async execute(
|
||||
callerId: string,
|
||||
name: string,
|
||||
args: unknown,
|
||||
options: NestedToolCallOptions = {},
|
||||
): Promise<AgentToolCallOutcome> {
|
||||
let scope = this.scopes.get(callerId);
|
||||
if (!scope) {
|
||||
scope = { recorder: new NestedCallRecorder(), nextId: 1, holdsQueue: false };
|
||||
this.scopes.set(callerId, scope);
|
||||
}
|
||||
const toolCall: AgentToolCall = {
|
||||
type: "toolCall",
|
||||
id: `${callerId}/${scope.nextId++}`,
|
||||
name,
|
||||
arguments: (args ?? {}) as AgentToolCall["arguments"],
|
||||
};
|
||||
const record = scope.recorder.start(toolCall);
|
||||
await this.host.emit({
|
||||
type: "tool_execution_start",
|
||||
toolCallId: toolCall.id,
|
||||
toolName: name,
|
||||
args: toolCall.arguments,
|
||||
parentToolCallId: callerId,
|
||||
});
|
||||
|
||||
const exclusive =
|
||||
!scope.holdsQueue &&
|
||||
(this.host.isSequential() ||
|
||||
this.host.getTools().find((tool) => tool.name === name)?.executionMode === "sequential");
|
||||
let release: (() => void) | undefined;
|
||||
if (exclusive) {
|
||||
const previous = this.queueTail;
|
||||
this.queueTail = new Promise((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
await previous;
|
||||
}
|
||||
this.scopes.set(toolCall.id, {
|
||||
recorder: scope.recorder,
|
||||
nextId: 1,
|
||||
holdsQueue: scope.holdsQueue || exclusive,
|
||||
});
|
||||
let outcome: AgentToolCallOutcome;
|
||||
try {
|
||||
outcome = await this.host.runToolCall(toolCall, callerId, options.signal, async (partialResult) => {
|
||||
options.onUpdate?.(partialResult);
|
||||
await this.host.emit({
|
||||
type: "tool_execution_update",
|
||||
toolCallId: toolCall.id,
|
||||
toolName: name,
|
||||
args: toolCall.arguments,
|
||||
partialResult,
|
||||
parentToolCallId: callerId,
|
||||
});
|
||||
});
|
||||
} finally {
|
||||
this.scopes.delete(toolCall.id);
|
||||
release?.();
|
||||
}
|
||||
|
||||
scope.recorder.finish(record, outcome.isError, textOf(outcome.result));
|
||||
await this.host.emit({
|
||||
type: "tool_execution_end",
|
||||
toolCallId: toolCall.id,
|
||||
toolName: name,
|
||||
result: outcome.result,
|
||||
isError: outcome.isError,
|
||||
parentToolCallId: callerId,
|
||||
});
|
||||
return outcome;
|
||||
}
|
||||
|
||||
/** Remove and return the record of the nested calls a model-issued call made. */
|
||||
takeRecord(toolCallId: string): NestedToolCalls | undefined {
|
||||
const scope = this.scopes.get(toolCallId);
|
||||
this.scopes.delete(toolCallId);
|
||||
return scope?.recorder.snapshot();
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.scopes.clear();
|
||||
}
|
||||
}
|
||||
|
|
@ -93,6 +93,21 @@ function mergeExtensionWarnings(
|
|||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Leave out replaceable extensions (see `InlineExtension`) that share a tool, command, or flag name
|
||||
* with another extension. For example, a third-party MCP extension that registers `/mcp` replaces
|
||||
* the built-in MCP extension instead of both connecting the same servers.
|
||||
*/
|
||||
function omitReplacedExtensions(extensions: Extension[]): Extension[] {
|
||||
const names = (extension: Extension) => [
|
||||
...[...extension.tools.keys()].map((name) => `tool:${name}`),
|
||||
...[...extension.commands.keys()].map((name) => `command:${name}`),
|
||||
...[...extension.flags.keys()].map((name) => `flag:${name}`),
|
||||
];
|
||||
const taken = new Set(extensions.filter((extension) => !extension.replaceable).flatMap(names));
|
||||
return extensions.filter((extension) => !extension.replaceable || !names(extension).some((name) => taken.has(name)));
|
||||
}
|
||||
|
||||
export interface ResourceLoader {
|
||||
getExtensions(): LoadExtensionsResult;
|
||||
getSkills(): { skills: Skill[]; diagnostics: ResourceDiagnostic[] };
|
||||
|
|
@ -630,6 +645,7 @@ export class DefaultResourceLoader implements ResourceLoader {
|
|||
const inlineExtensions = await this.loadExtensionFactories(extensionsResult.runtime);
|
||||
extensionsResult.extensions.push(...inlineExtensions.extensions);
|
||||
extensionsResult.errors.push(...inlineExtensions.errors);
|
||||
extensionsResult.extensions = omitReplacedExtensions(extensionsResult.extensions);
|
||||
return extensionsResult;
|
||||
}
|
||||
|
||||
|
|
@ -644,7 +660,10 @@ export class DefaultResourceLoader implements ResourceLoader {
|
|||
if (!preTrustExtensions) {
|
||||
const extensionsResult = await loadExtensionsCached(extensionPaths, this.cwd, this.eventBus);
|
||||
const inlineExtensions = await this.loadExtensionFactories(extensionsResult.runtime);
|
||||
extensionsResult.extensions.push(...inlineExtensions.extensions);
|
||||
extensionsResult.extensions = omitReplacedExtensions([
|
||||
...extensionsResult.extensions,
|
||||
...inlineExtensions.extensions,
|
||||
]);
|
||||
extensionsResult.errors.push(...inlineExtensions.errors);
|
||||
this.addExtensionConflictDiagnostics(extensionsResult);
|
||||
return extensionsResult;
|
||||
|
|
@ -682,7 +701,7 @@ export class DefaultResourceLoader implements ResourceLoader {
|
|||
orderedExtensions.push(...inlineExtensions);
|
||||
|
||||
const extensionsResult: LoadExtensionsResult = {
|
||||
extensions: orderedExtensions,
|
||||
extensions: omitReplacedExtensions(orderedExtensions),
|
||||
errors: [...preTrustExtensions.errors, ...remainingExtensions.errors],
|
||||
warnings: [...(preTrustExtensions.warnings ?? []), ...(remainingExtensions.warnings ?? [])],
|
||||
runtime: preTrustExtensions.runtime,
|
||||
|
|
@ -1044,6 +1063,7 @@ export class DefaultResourceLoader implements ResourceLoader {
|
|||
try {
|
||||
const extension = await loadExtensionFromFactory(factory, this.cwd, this.eventBus, runtime, extensionPath);
|
||||
extension.hidden = isNamed && input.hidden;
|
||||
extension.replaceable = isNamed && input.replaceable === true;
|
||||
extensions.push(extension);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "failed to load extension";
|
||||
|
|
|
|||
|
|
@ -86,6 +86,22 @@ export interface WarningSettings {
|
|||
anthropicExtraUsage?: boolean; // default: true
|
||||
}
|
||||
|
||||
/**
|
||||
* How the codemode tool presents tools while it is active.
|
||||
* - `on`: declared tools that scripts can call get their codemode declaration appended to their
|
||||
* description; the codemode description lists only the tools without `direct` exposure.
|
||||
* - `only`: the codemode description lists every tool scripts can call, and active `direct` tools are
|
||||
* not declared to the model.
|
||||
*/
|
||||
export type CodemodeMode = "on" | "only";
|
||||
|
||||
export interface CodemodeSettings {
|
||||
/** Default: `on`. */
|
||||
mode?: CodemodeMode;
|
||||
/** Estimated tokens (characters / 4) the codemode description may spend on tool declarations. Default: 3000. */
|
||||
inlineBudget?: number;
|
||||
}
|
||||
|
||||
export type DefaultProjectTrust = "ask" | "always" | "never";
|
||||
|
||||
export type TransportSetting = Transport;
|
||||
|
|
@ -151,6 +167,7 @@ export interface Settings {
|
|||
showHardwareCursor?: boolean; // Show terminal cursor while still positioning it for IME
|
||||
markdown?: MarkdownSettings;
|
||||
warnings?: WarningSettings;
|
||||
codemode?: CodemodeSettings;
|
||||
sessionDir?: string; // Custom session storage directory (same format as --session-dir CLI flag)
|
||||
httpProxy?: string; // Proxy URL applied as HTTP_PROXY and HTTPS_PROXY for Pi-managed HTTP clients
|
||||
httpIdleTimeoutMs?: number; // HTTP header/body idle timeout in milliseconds; 0 disables it
|
||||
|
|
@ -499,6 +516,11 @@ export class SettingsManager {
|
|||
return settings as Settings;
|
||||
}
|
||||
|
||||
/** A copy of the effective settings: global and project settings merged, with overrides. */
|
||||
getSettings(): Settings {
|
||||
return structuredClone(this.settings);
|
||||
}
|
||||
|
||||
getGlobalSettings(): Settings {
|
||||
return structuredClone(this.globalSettings);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -155,7 +155,7 @@ export function buildSystemPromptSections(input: BuildSystemPromptOptions): Syst
|
|||
- Additional docs: ${getDocsPath()}
|
||||
- Examples: ${getExamplesPath()} (extensions, custom tools, SDK)
|
||||
- When reading pi docs or examples, resolve docs/... under Additional docs and examples/... under Examples, not the current working directory
|
||||
- When asked about: extensions (docs/extensions.md, examples/extensions/), themes (docs/themes.md), skills (docs/skills.md), prompt templates (docs/prompt-templates.md), TUI components (docs/tui.md), keybindings (docs/keybindings.md), SDK integrations (docs/sdk.md), custom providers (docs/custom-provider.md), adding models (docs/models.md), pi packages (docs/packages.md), environment variables (docs/environment-variables.md)
|
||||
- When asked about: extensions (docs/extensions.md, examples/extensions/), themes (docs/themes.md), skills (docs/skills.md), prompt templates (docs/prompt-templates.md), TUI components (docs/tui.md), keybindings (docs/keybindings.md), SDK integrations (docs/sdk.md), custom providers (docs/custom-provider.md), adding models (docs/models.md), pi packages (docs/packages.md), environment variables (docs/environment-variables.md), MCP servers (docs/mcp.md)
|
||||
- When working on pi topics, read the docs and examples, and follow .md cross-references before implementing
|
||||
- Always read pi .md files completely and follow links to related docs (e.g., tui.md for TUI API details)`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,6 +47,17 @@ export const bashToolSystemPromptContribution = {
|
|||
|
||||
export type BashToolInput = Static<typeof bashSchema>;
|
||||
|
||||
/**
|
||||
* Result for programmatic callers such as codemode scripts. A non-zero exit code is an error result for the model, but scripts still resolve to this value.
|
||||
*/
|
||||
const bashOutputSchema = Type.Object({
|
||||
output: Type.String({ description: "Combined stdout and stderr, truncated like the model-facing output" }),
|
||||
exit_code: Type.Number(),
|
||||
wall_time_seconds: Type.Number(),
|
||||
});
|
||||
|
||||
export type BashToolOutput = Static<typeof bashOutputSchema>;
|
||||
|
||||
export interface BashToolDetails {
|
||||
truncation?: TruncationResult;
|
||||
fullOutputPath?: string;
|
||||
|
|
@ -240,6 +251,7 @@ export function createShellToolDefinition(
|
|||
promptSnippet: config.promptSnippet,
|
||||
promptGuidelines: exposeSessionEnvironment && config.promptGuidelines ? [...config.promptGuidelines] : undefined,
|
||||
parameters: bashSchema,
|
||||
outputSchema: bashOutputSchema,
|
||||
constrainedSampling: { type: "json_schema", strict: "prefer" },
|
||||
async execute(
|
||||
_toolCallId,
|
||||
|
|
@ -339,6 +351,7 @@ export function createShellToolDefinition(
|
|||
};
|
||||
|
||||
const appendStatus = (text: string, status: string) => `${text ? `${text}\n\n` : ""}${status}`;
|
||||
const startedAt = performance.now();
|
||||
|
||||
try {
|
||||
let exitCode: number | null;
|
||||
|
|
@ -368,10 +381,20 @@ export function createShellToolDefinition(
|
|||
if (exitCode === null) {
|
||||
throw new Error(appendStatus(outputText, "Command terminated without an exit code"));
|
||||
}
|
||||
const structuredContent: BashToolOutput = {
|
||||
output: outputText,
|
||||
exit_code: exitCode,
|
||||
wall_time_seconds: Math.round((performance.now() - startedAt) / 100) / 10,
|
||||
};
|
||||
if (exitCode !== 0) {
|
||||
throw new Error(appendStatus(outputText, `Command exited with code ${exitCode}`));
|
||||
return {
|
||||
content: [{ type: "text", text: appendStatus(outputText, `Command exited with code ${exitCode}`) }],
|
||||
details,
|
||||
structuredContent,
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
return { content: [{ type: "text", text: outputText }], details };
|
||||
return { content: [{ type: "text", text: outputText }], details, structuredContent };
|
||||
} finally {
|
||||
clearUpdateTimer();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,28 +1,38 @@
|
|||
import type { AgentTool } from "@earendil-works/pi-agent-core";
|
||||
import type { ExtensionContext, ToolDefinition } from "../extensions/types.ts";
|
||||
import type { ExtensionToolContext, ToolDefinition } from "../extensions/types.ts";
|
||||
|
||||
/** Creates the context for one tool call. */
|
||||
export type ToolContextFactory = (toolCallId: string, signal: AbortSignal | undefined) => ExtensionToolContext;
|
||||
|
||||
/** Wrap a ToolDefinition into an AgentTool for the core runtime. */
|
||||
export function wrapToolDefinition<TDetails = unknown>(
|
||||
definition: ToolDefinition<any, TDetails>,
|
||||
ctxFactory?: () => ExtensionContext,
|
||||
ctxFactory?: ToolContextFactory,
|
||||
): AgentTool<any, TDetails> {
|
||||
return {
|
||||
name: definition.name,
|
||||
label: definition.label,
|
||||
description: definition.description,
|
||||
parameters: definition.parameters,
|
||||
outputSchema: definition.outputSchema,
|
||||
constrainedSampling: definition.constrainedSampling,
|
||||
prepareArguments: definition.prepareArguments,
|
||||
executionMode: definition.executionMode,
|
||||
execute: (toolCallId, params, signal, onUpdate, ctx?: ExtensionContext) =>
|
||||
definition.execute(toolCallId, params, signal, onUpdate, ctx ?? (ctxFactory?.() as ExtensionContext)),
|
||||
execute: (toolCallId, params, signal, onUpdate, ctx?: ExtensionToolContext) =>
|
||||
definition.execute(
|
||||
toolCallId,
|
||||
params,
|
||||
signal,
|
||||
onUpdate,
|
||||
ctx ?? (ctxFactory?.(toolCallId, signal) as ExtensionToolContext),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/** Wrap multiple ToolDefinitions into AgentTools for the core runtime. */
|
||||
export function wrapToolDefinitions(
|
||||
definitions: ToolDefinition<any, any>[],
|
||||
ctxFactory?: () => ExtensionContext,
|
||||
ctxFactory?: ToolContextFactory,
|
||||
): AgentTool<any>[] {
|
||||
return definitions.map((definition) => wrapToolDefinition(definition, ctxFactory));
|
||||
}
|
||||
|
|
@ -39,6 +49,7 @@ export function createToolDefinitionFromAgentTool(tool: AgentTool<any>): ToolDef
|
|||
label: tool.label,
|
||||
description: tool.description,
|
||||
parameters: tool.parameters as any,
|
||||
outputSchema: tool.outputSchema,
|
||||
constrainedSampling: tool.constrainedSampling,
|
||||
prepareArguments: tool.prepareArguments,
|
||||
executionMode: tool.executionMode,
|
||||
|
|
|
|||
|
|
@ -274,3 +274,41 @@ export function truncateLine(
|
|||
}
|
||||
return { text: `${line.slice(0, maxChars)}... [truncated]`, wasTruncated: true };
|
||||
}
|
||||
|
||||
export interface MiddleTruncationResult {
|
||||
/** The start and end of the content with a `…N chars truncated…` marker between them. */
|
||||
content: string;
|
||||
truncated: boolean;
|
||||
/** Characters left out. */
|
||||
removedChars: number;
|
||||
totalBytes: number;
|
||||
totalLines: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep the start and the end of `content`, half of `maxBytes` each, and replace the middle with a
|
||||
* `…N chars truncated…` marker, like Codex does for tool output. Cuts only at character boundaries.
|
||||
*/
|
||||
export function truncateMiddle(content: string, maxBytes: number): MiddleTruncationResult {
|
||||
const buf = Buffer.from(content, "utf-8");
|
||||
const totalLines = splitLinesForCounting(content).length;
|
||||
if (buf.length <= maxBytes) {
|
||||
return { content, truncated: false, removedChars: 0, totalBytes: buf.length, totalLines };
|
||||
}
|
||||
// Continuation bytes (10xxxxxx) are not character starts.
|
||||
const isBoundary = (index: number) => index >= buf.length || (buf[index] & 0xc0) !== 0x80;
|
||||
let headEnd = Math.floor(maxBytes / 2);
|
||||
while (headEnd > 0 && !isBoundary(headEnd)) headEnd--;
|
||||
let tailStart = buf.length - (maxBytes - Math.floor(maxBytes / 2));
|
||||
while (tailStart < buf.length && !isBoundary(tailStart)) tailStart++;
|
||||
const head = buf.subarray(0, headEnd).toString("utf-8");
|
||||
const tail = buf.subarray(tailStart).toString("utf-8");
|
||||
const removedChars = Array.from(buf.subarray(headEnd, tailStart).toString("utf-8")).length;
|
||||
return {
|
||||
content: `${head}…${removedChars} chars truncated…${tail}`,
|
||||
truncated: true,
|
||||
removedChars,
|
||||
totalBytes: buf.length,
|
||||
totalLines,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ type TrustFile = Record<string, boolean | null | undefined>;
|
|||
|
||||
const TRUST_REQUIRING_PROJECT_CONFIG_RESOURCES = [
|
||||
"settings.json",
|
||||
"mcp.json",
|
||||
"extensions",
|
||||
"skills",
|
||||
"prompts",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue