feat: sync upstream pi-mono (386 commits)

从上游 badlogic/pi-mono 同步 386 个提交,手动解决所有冲突:

保留 SproutClaw 独有功能:
- RPC: reload 命令、bash 流式输出、get_extensions 命令
- settings-manager: showChangelogOnStartup 设置
- agent-session: turnIndex getter
- 移除 pi 版本检查通知(interactive-mode)
- skills 系统、启动脚本、自定义工具脚本

直接采用上游版本:
- packages/ai 模型列表及测试文件
- packages/coding-agent 文档
- 其余未冲突的全部上游代码

升级 @mistralai/mistralai 至 2.2.6(修复 promptCacheKey 类型错误)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-21 20:18:02 +08:00
409 changed files with 27402 additions and 6514 deletions

View File

@@ -2,6 +2,64 @@
## [Unreleased]
## [0.79.9] - 2026-06-20
### Fixed
- Fixed Node execution environment commands through legacy WSL `bash.exe` to pass scripts over stdin so shell variables expand in the target bash ([#5893](https://github.com/earendil-works/pi/issues/5893)).
## [0.79.8] - 2026-06-19
### Added
- Added `@earendil-works/pi-agent-core/base` for bundlers that want to pair the agent core with selective `@earendil-works/pi-ai/base` provider registration ([#5348](https://github.com/earendil-works/pi/pull/5348) by [@FredKSchott](https://github.com/FredKSchott)).
## [0.79.7] - 2026-06-18
## [0.79.6] - 2026-06-16
## [0.79.5] - 2026-06-16
## [0.79.4] - 2026-06-15
## [0.79.3] - 2026-06-13
## [0.79.2] - 2026-06-12
### Fixed
- Fixed late tool progress callbacks after tool settlement to be ignored instead of emitting stale `tool_execution_update` events ([#5573](https://github.com/earendil-works/pi/issues/5573)).
## [0.79.1] - 2026-06-09
## [0.79.0] - 2026-06-08
### Fixed
- Fixed the compaction summarization system prompt to use neutral AI assistant wording for non-coding agents ([#5401](https://github.com/earendil-works/pi/issues/5401)).
## [0.78.1] - 2026-06-04
## [0.78.0] - 2026-05-29
## [0.77.0] - 2026-05-28
### Breaking Changes
- Renamed agent harness `model_select` and `thinking_level_select` events to `model_update` and `thinking_level_update`.
### Added
- Added agent harness tool registry APIs, `tools_update` events, branch-scoped active-tool persistence, and duplicate tool validation.
## [0.76.0] - 2026-05-27
### Fixed
- Fixed context token estimates to count user image attachments consistently with tool result images ([#4983](https://github.com/earendil-works/pi/issues/4983)).
## [0.75.5] - 2026-05-23
## [0.75.4] - 2026-05-20
### Changed

View File

@@ -31,6 +31,24 @@ agent.subscribe((event) => {
await agent.prompt("Hello!");
```
## Base Entry Point
Use `@earendil-works/pi-agent-core/base` with `@earendil-works/pi-ai/base` when bundling applications that should include only selected provider transports:
```typescript
import { Agent } from "@earendil-works/pi-agent-core/base";
import { getModel } from "@earendil-works/pi-ai/base";
import { register } from "@earendil-works/pi-ai/anthropic";
register();
const agent = new Agent({
initialState: { model: getModel("anthropic", "claude-sonnet-4-6") },
});
```
The default `@earendil-works/pi-agent-core` entry point remains batteries-included and registers pi-ai's lazy built-in transports for backward compatibility.
## Core Concepts
### AgentMessage vs LLM Message

View File

@@ -258,13 +258,14 @@ Done:
- Exported `QueueMode` from core types.
- Added `AgentHarnessOptions.steeringMode` and `followUpMode`.
- Added live `getSteeringMode()` / `setSteeringMode()` and `getFollowUpMode()` / `setFollowUpMode()`.
- Added `getTools()` and `getActiveTools()`.
- Added `tools_update` observability events, including active-tool-only updates.
- Active tool changes are persisted as branch-scoped `active_tools_change` entries.
- Duplicate tool names and duplicate active tool names reject.
Remaining:
- Add `getTools()` semantics.
- Add `getActiveTools()` semantics.
- Decide and implement tool update observability events.
- Include active-tool-only updates in the runtime config observability plan.
- None.
Notes:

View File

@@ -14,6 +14,8 @@ A fully durable `AgentHarness` is not realistic by itself because important depe
- resource loaders
- system-prompt callbacks/modifiers
Tool registries are runtime dependencies. The harness should persist serializable tool configuration, such as active tool names, but not concrete tool implementations.
The practical target is a semi-durable harness:
- session is the durable append-only state tree
@@ -29,6 +31,7 @@ Existing session state already includes harness state:
- model changes
- thinking-level changes
- active-tool changes
- leaf entries
- labels
- compactions and branch summaries
@@ -50,10 +53,38 @@ The app must recreate compatible runtime dependencies:
Harness can validate stable IDs/versions/hashes when available, but it cannot serialize these dependencies itself.
## Runtime configuration and restore
Constructor options remain explicit runtime configuration and do not read session state. Hidden async restore in a constructor would make failure handling ambiguous.
A future async builder/factory should own durable restore:
```ts
const harness = await AgentHarness.builder()
.env(env)
.session(session)
.model(defaultModel)
.tools(runtimeTools)
.defaultActiveTools(["read", "edit"])
.restore({ missingActiveTools: "fail" });
```
`restore()` should read the active branch, reduce durable harness configuration, apply defaults for missing entries, validate against app-supplied runtime dependencies, construct the harness, and optionally emit `source: "restore"` update events after construction.
For active tools:
- `active_tools_change` entries are branch-scoped durable config.
- If no `active_tools_change` exists on the branch, restore uses builder defaults, or all registered tools if no default active names were supplied.
- Active tool names must be unique.
- Tool registry names must be unique.
- Missing restored active tool names should fail restore by default; permissive drop/disable policies can be added explicitly later.
- Concrete tools are never restored from session; the host app must provide compatible tools.
## What harness should persist
Minimum useful durability entries:
- branch-scoped active tool names
- queued steer/followUp/nextTurn messages
- queue consumption tied to a turn
- pending session writes accepted during active operations
@@ -93,11 +124,11 @@ On startup:
3. Harness reduces session entries into:
- current leaf
- conversation branch
- harness config
- harness config, including active tool names
- queues
- pending writes
- active operation/turn/tool state
4. Harness validates required runtime dependencies.
4. Harness validates required runtime dependencies, including restored active tool names against the app-provided tool registry.
5. Harness reconciles unfinished operation state.
Provider streams are not resumable. Recovery can only retry from a durable boundary or mark the operation interrupted.
@@ -173,7 +204,7 @@ recovery: "mark_interrupted" | "retry_unfinished"
## Open questions
- Which harness config entries should move into session first: tools, active tools, resources, stream options, system prompt refs?
- Which remaining harness config entries should move into session first: resources, stream options, system prompt refs?
- Should resolved system prompt text be snapshotted per turn for audit/debug?
- Do we require strict dependency ID/version matching on resume?
- How much provider request data should be journaled?

View File

@@ -1,6 +1,6 @@
{
"name": "@earendil-works/pi-agent-core",
"version": "0.75.4",
"version": "0.79.9",
"description": "General-purpose agent with transport abstraction, state management, and attachment support",
"type": "module",
"main": "./dist/index.js",
@@ -10,6 +10,10 @@
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./base": {
"types": "./dist/base.d.ts",
"import": "./dist/base.js"
},
"./node": {
"types": "./dist/node.d.ts",
"import": "./dist/node.js"
@@ -29,7 +33,7 @@
"prepublishOnly": "npm run clean && npm run build"
},
"dependencies": {
"@earendil-works/pi-ai": "^0.75.4",
"@earendil-works/pi-ai": "^0.79.9",
"ignore": "7.0.5",
"typebox": "1.1.38",
"yaml": "2.9.0"
@@ -45,7 +49,7 @@
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/earendil-works/pi-mono.git",
"url": "git+https://github.com/earendil-works/pi.git",
"directory": "packages/agent"
},
"engines": {
@@ -53,8 +57,8 @@
},
"devDependencies": {
"@types/node": "24.12.4",
"@vitest/coverage-v8": "3.2.4",
"@vitest/coverage-v8": "4.1.9",
"typescript": "5.9.3",
"vitest": "3.2.4"
"vitest": "4.1.9"
}
}

View File

@@ -10,7 +10,7 @@ import {
streamSimple,
type ToolResultMessage,
validateToolArguments,
} from "@earendil-works/pi-ai";
} from "@earendil-works/pi-ai/base";
import type {
AgentContext,
AgentEvent,
@@ -631,6 +631,7 @@ async function executePreparedToolCall(
emit: AgentEventSink,
): Promise<ExecutedToolCallOutcome> {
const updateEvents: Promise<void>[] = [];
let acceptingUpdates = true;
try {
const result = await prepared.tool.execute(
@@ -638,6 +639,7 @@ async function executePreparedToolCall(
prepared.args as never,
signal,
(partialResult) => {
if (!acceptingUpdates) return;
updateEvents.push(
Promise.resolve(
emit({
@@ -651,14 +653,18 @@ async function executePreparedToolCall(
);
},
);
acceptingUpdates = false;
await Promise.all(updateEvents);
return { result, isError: false };
} catch (error) {
acceptingUpdates = false;
await Promise.all(updateEvents);
return {
result: createErrorToolResult(error instanceof Error ? error.message : String(error)),
isError: true,
};
} finally {
acceptingUpdates = false;
}
}

View File

@@ -7,7 +7,7 @@ import {
type TextContent,
type ThinkingBudgets,
type Transport,
} from "@earendil-works/pi-ai";
} from "@earendil-works/pi-ai/base";
import { runAgentLoop, runAgentLoopContinue } from "./agent-loop.ts";
import type {
AfterToolCallContext,

View File

@@ -0,0 +1,39 @@
export * from "./agent.ts";
export * from "./agent-loop.ts";
export * from "./harness/agent-harness.ts";
export {
type BranchPreparation,
type BranchSummaryDetails,
type CollectEntriesResult,
collectEntriesForBranchSummary,
generateBranchSummary,
prepareBranchEntries,
} from "./harness/compaction/branch-summarization.ts";
export {
calculateContextTokens,
compact,
DEFAULT_COMPACTION_SETTINGS,
estimateContextTokens,
estimateTokens,
findCutPoint,
findTurnStartIndex,
generateSummary,
getLastAssistantUsage,
prepareCompaction,
serializeConversation,
shouldCompact,
} from "./harness/compaction/compaction.ts";
export * from "./harness/messages.ts";
export * from "./harness/prompt-templates.ts";
export * from "./harness/session/jsonl-repo.ts";
export * from "./harness/session/memory-repo.ts";
export * from "./harness/session/repo-utils.ts";
export * from "./harness/session/session.ts";
export { uuidv7 } from "./harness/session/uuid.ts";
export * from "./harness/skills.ts";
export * from "./harness/system-prompt.ts";
export * from "./harness/types.ts";
export * from "./harness/utils/shell-output.ts";
export * from "./harness/utils/truncate.ts";
export * from "./proxy.ts";
export * from "./types.ts";

View File

@@ -4,7 +4,7 @@ import {
type Model,
streamSimple,
type UserMessage,
} from "@earendil-works/pi-ai";
} from "@earendil-works/pi-ai/base";
import { runAgentLoop } from "../agent-loop.ts";
import type {
AgentContext,
@@ -86,6 +86,16 @@ function mergeHeaders(...headers: Array<Record<string, string> | undefined>): Re
return hasHeaders ? merged : undefined;
}
function findDuplicateNames(names: string[]): string[] {
const seen = new Set<string>();
const duplicates = new Set<string>();
for (const name of names) {
if (seen.has(name)) duplicates.add(name);
seen.add(name);
}
return [...duplicates];
}
function applyStreamOptionsPatch(
base: AgentHarnessStreamOptions,
patch?: AgentHarnessStreamOptionsPatch,
@@ -194,12 +204,20 @@ export class AgentHarness<
this.streamOptions = cloneStreamOptions(options.streamOptions);
this.systemPrompt = options.systemPrompt;
this.getApiKeyAndHeaders = options.getApiKeyAndHeaders;
this.validateUniqueNames(
(options.tools ?? []).map((tool) => tool.name),
"Duplicate tool name(s)",
);
for (const tool of options.tools ?? []) {
this.tools.set(tool.name, tool);
}
this.model = options.model;
this.thinkingLevel = options.thinkingLevel ?? "off";
this.activeToolNames = options.activeToolNames ?? (options.tools ?? []).map((tool) => tool.name);
this.activeToolNames = options.activeToolNames
? [...options.activeToolNames]
: (options.tools ?? []).map((tool) => tool.name);
this.validateUniqueNames(this.activeToolNames, "Duplicate active tool name(s)");
this.validateToolNames(this.activeToolNames);
this.steeringQueueMode = options.steeringMode ?? "one-at-a-time";
this.followUpQueueMode = options.followUpMode ?? "one-at-a-time";
}
@@ -451,7 +469,14 @@ export class AgentHarness<
};
}
private validateUniqueNames(names: string[], message: string): void {
const duplicates = findDuplicateNames(names);
if (duplicates.length > 0)
throw new AgentHarnessError("invalid_argument", `${message}: ${duplicates.join(", ")}`);
}
private validateToolNames(toolNames: string[], tools: Map<string, TTool> = this.tools): void {
this.validateUniqueNames(toolNames, "Duplicate active tool name(s)");
const missing = toolNames.filter((name) => !tools.has(name));
if (missing.length > 0) throw new AgentHarnessError("invalid_argument", `Unknown tool(s): ${missing.join(", ")}`);
}
@@ -465,6 +490,8 @@ export class AgentHarness<
await this.session.appendModelChange(write.provider, write.modelId);
} else if (write.type === "thinking_level_change") {
await this.session.appendThinkingLevelChange(write.thinkingLevel);
} else if (write.type === "active_tools_change") {
await this.session.appendActiveToolsChange(write.activeToolNames);
} else if (write.type === "custom") {
await this.session.appendCustomEntry(write.customType, write.data);
} else if (write.type === "custom_message") {
@@ -838,10 +865,6 @@ export class AgentHarness<
return this.model;
}
getThinkingLevel(): ThinkingLevel {
return this.thinkingLevel;
}
async setModel(model: Model<any>): Promise<void> {
try {
const previousModel = this.model;
@@ -851,12 +874,16 @@ export class AgentHarness<
this.pendingSessionWrites.push({ type: "model_change", provider: model.provider, modelId: model.id });
}
this.model = model;
await this.emitOwn({ type: "model_select", model, previousModel, source: "set" });
await this.emitOwn({ type: "model_update", model, previousModel, source: "set" });
} catch (error) {
throw normalizeHarnessError(error, "session");
}
}
getThinkingLevel(): ThinkingLevel {
return this.thinkingLevel;
}
async setThinkingLevel(level: ThinkingLevel): Promise<void> {
try {
const previousLevel = this.thinkingLevel;
@@ -866,16 +893,70 @@ export class AgentHarness<
this.pendingSessionWrites.push({ type: "thinking_level_change", thinkingLevel: level });
}
this.thinkingLevel = level;
await this.emitOwn({ type: "thinking_level_select", level, previousLevel });
await this.emitOwn({ type: "thinking_level_update", level, previousLevel });
} catch (error) {
throw normalizeHarnessError(error, "session");
}
}
getTools(): TTool[] {
return [...this.tools.values()];
}
async setTools(tools: TTool[], activeToolNames?: string[]): Promise<void> {
try {
this.validateUniqueNames(
tools.map((tool) => tool.name),
"Duplicate tool name(s)",
);
const nextTools = new Map(tools.map((tool) => [tool.name, tool]));
const nextActiveToolNames = activeToolNames ? [...activeToolNames] : this.activeToolNames;
this.validateToolNames(nextActiveToolNames, nextTools);
const previousToolNames = [...this.tools.keys()];
const previousActiveToolNames = [...this.activeToolNames];
if (this.phase === "idle") {
await this.session.appendActiveToolsChange(nextActiveToolNames);
} else {
this.pendingSessionWrites.push({ type: "active_tools_change", activeToolNames: [...nextActiveToolNames] });
}
this.tools = nextTools;
this.activeToolNames = [...nextActiveToolNames];
await this.emitOwn({
type: "tools_update",
toolNames: [...this.tools.keys()],
previousToolNames,
activeToolNames: [...this.activeToolNames],
previousActiveToolNames,
source: "set",
});
} catch (error) {
throw normalizeHarnessError(error, "invalid_argument");
}
}
getActiveTools(): TTool[] {
return this.activeToolNames.map((name) => this.tools.get(name)!);
}
async setActiveTools(toolNames: string[]): Promise<void> {
try {
this.validateToolNames(toolNames);
const previousToolNames = [...this.tools.keys()];
const previousActiveToolNames = [...this.activeToolNames];
if (this.phase === "idle") {
await this.session.appendActiveToolsChange(toolNames);
} else {
this.pendingSessionWrites.push({ type: "active_tools_change", activeToolNames: [...toolNames] });
}
this.activeToolNames = [...toolNames];
await this.emitOwn({
type: "tools_update",
toolNames: [...this.tools.keys()],
previousToolNames,
activeToolNames: [...this.activeToolNames],
previousActiveToolNames,
source: "set",
});
} catch (error) {
throw normalizeHarnessError(error, "invalid_argument");
}
@@ -921,18 +1002,6 @@ export class AgentHarness<
this.streamOptions = cloneStreamOptions(streamOptions);
}
async setTools(tools: TTool[], activeToolNames?: string[]): Promise<void> {
try {
const nextTools = new Map(tools.map((tool) => [tool.name, tool]));
const nextActiveToolNames = activeToolNames ? [...activeToolNames] : this.activeToolNames;
this.validateToolNames(nextActiveToolNames, nextTools);
this.tools = nextTools;
this.activeToolNames = [...nextActiveToolNames];
} catch (error) {
throw normalizeHarnessError(error, "invalid_argument");
}
}
async abort(): Promise<AbortResult> {
const clearedSteer = [...this.steerQueue];
const clearedFollowUp = [...this.followUpQueue];

View File

@@ -1,5 +1,4 @@
import type { Model } from "@earendil-works/pi-ai";
import { completeSimple } from "@earendil-works/pi-ai";
import { completeSimple, type Model } from "@earendil-works/pi-ai/base";
import type { AgentMessage } from "../../types.ts";
import {
convertToLlm,
@@ -112,6 +111,7 @@ function getMessageFromEntry(entry: SessionTreeEntry): AgentMessage | undefined
return createCompactionSummaryMessage(entry.summary, entry.tokensBefore, entry.timestamp);
case "thinking_level_change":
case "model_change":
case "active_tools_change":
case "custom":
case "label":
case "session_info":

View File

@@ -1,5 +1,11 @@
import type { AssistantMessage, ImageContent, Model, TextContent, Usage } from "@earendil-works/pi-ai";
import { completeSimple } from "@earendil-works/pi-ai";
import {
type AssistantMessage,
completeSimple,
type ImageContent,
type Model,
type TextContent,
type Usage,
} from "@earendil-works/pi-ai/base";
import type { AgentMessage, ThinkingLevel } from "../../types.ts";
import {
convertToLlm,
@@ -198,22 +204,33 @@ export function shouldCompact(contextTokens: number, contextWindow: number, sett
return contextTokens > contextWindow - settings.reserveTokens;
}
const ESTIMATED_IMAGE_CHARS = 4800;
function estimateTextAndImageContentChars(content: string | Array<{ type: string; text?: string }>): number {
if (typeof content === "string") {
return content.length;
}
let chars = 0;
for (const block of content) {
if (block.type === "text" && block.text) {
chars += block.text.length;
} else if (block.type === "image") {
chars += ESTIMATED_IMAGE_CHARS;
}
}
return chars;
}
/** Estimate token count for one message using a conservative character heuristic. */
export function estimateTokens(message: AgentMessage): number {
let chars = 0;
switch (message.role) {
case "user": {
const content = (message as { content: string | Array<{ type: string; text?: string }> }).content;
if (typeof content === "string") {
chars = content.length;
} else if (Array.isArray(content)) {
for (const block of content) {
if (block.type === "text" && block.text) {
chars += block.text.length;
}
}
}
chars = estimateTextAndImageContentChars(
(message as { content: string | Array<{ type: string; text?: string }> }).content,
);
return Math.ceil(chars / 4);
}
case "assistant": {
@@ -231,18 +248,7 @@ export function estimateTokens(message: AgentMessage): number {
}
case "custom":
case "toolResult": {
if (typeof message.content === "string") {
chars = message.content.length;
} else {
for (const block of message.content) {
if (block.type === "text" && block.text) {
chars += block.text.length;
}
if (block.type === "image") {
chars += 4800;
}
}
}
chars = estimateTextAndImageContentChars(message.content);
return Math.ceil(chars / 4);
}
case "bashExecution": {
@@ -281,6 +287,7 @@ function findValidCutPoints(entries: SessionTreeEntry[], startIndex: number, end
}
case "thinking_level_change":
case "model_change":
case "active_tools_change":
case "compaction":
case "branch_summary":
case "custom":
@@ -375,7 +382,7 @@ export function findCutPoint(
};
}
export const SUMMARIZATION_SYSTEM_PROMPT = `You are a context summarization assistant. Your task is to read a conversation between a user and an AI coding assistant, then produce a structured summary following the exact format specified.
export const SUMMARIZATION_SYSTEM_PROMPT = `You are a context summarization assistant. Your task is to read a conversation between a user and an AI assistant, then produce a structured summary following the exact format specified.
Do NOT continue the conversation. Do NOT respond to any questions in the conversation. ONLY output the structured summary.`;

View File

@@ -1,4 +1,4 @@
import type { Message } from "@earendil-works/pi-ai";
import type { Message } from "@earendil-works/pi-ai/base";
import type { AgentMessage } from "../../types.ts";
/** File paths touched by a session branch or compaction range. */

View File

@@ -144,12 +144,25 @@ async function findBashOnPath(): Promise<string | null> {
return firstMatch && (await pathExists(firstMatch)) ? firstMatch : null;
}
async function getShellConfig(
customShellPath?: string,
): Promise<Result<{ shell: string; args: string[] }, ExecutionError>> {
interface ShellConfig {
shell: string;
args: string[];
commandTransport?: "argv" | "stdin";
}
function isLegacyWslBashPath(path: string): boolean {
const normalized = path.replace(/\//g, "\\").toLowerCase();
return /^[a-z]:\\windows\\(?:system32|sysnative)\\bash\.exe$/.test(normalized);
}
function getBashShellConfig(shell: string): ShellConfig {
return isLegacyWslBashPath(shell) ? { shell, args: ["-s"], commandTransport: "stdin" } : { shell, args: ["-c"] };
}
async function getShellConfig(customShellPath?: string): Promise<Result<ShellConfig, ExecutionError>> {
if (customShellPath) {
if (await pathExists(customShellPath)) {
return ok({ shell: customShellPath, args: ["-c"] });
return ok(getBashShellConfig(customShellPath));
}
return err(new ExecutionError("shell_unavailable", `Custom shell path not found: ${customShellPath}`));
}
@@ -161,22 +174,22 @@ async function getShellConfig(
if (programFilesX86) candidates.push(`${programFilesX86}\\Git\\bin\\bash.exe`);
for (const candidate of candidates) {
if (await pathExists(candidate)) {
return ok({ shell: candidate, args: ["-c"] });
return ok(getBashShellConfig(candidate));
}
}
const bashOnPath = await findBashOnPath();
if (bashOnPath) {
return ok({ shell: bashOnPath, args: ["-c"] });
return ok(getBashShellConfig(bashOnPath));
}
return err(new ExecutionError("shell_unavailable", "No bash shell found"));
}
if (await pathExists("/bin/bash")) {
return ok({ shell: "/bin/bash", args: ["-c"] });
return ok(getBashShellConfig("/bin/bash"));
}
const bashOnPath = await findBashOnPath();
if (bashOnPath) {
return ok({ shell: bashOnPath, args: ["-c"] });
return ok(getBashShellConfig(bashOnPath));
}
return ok({ shell: "sh", args: ["-c"] });
}
@@ -274,13 +287,22 @@ export class NodeExecutionEnv implements ExecutionEnv {
};
try {
child = spawn(shellConfig.value.shell, [...shellConfig.value.args, command], {
cwd,
detached: process.platform !== "win32",
env: getShellEnv(this.shellEnv, options?.env),
stdio: ["ignore", "pipe", "pipe"],
windowsHide: true,
});
const commandFromStdin = shellConfig.value.commandTransport === "stdin";
child = spawn(
shellConfig.value.shell,
commandFromStdin ? shellConfig.value.args : [...shellConfig.value.args, command],
{
cwd,
detached: process.platform !== "win32",
env: getShellEnv(this.shellEnv, options?.env),
stdio: [commandFromStdin ? "pipe" : "ignore", "pipe", "pipe"],
windowsHide: true,
},
);
if (commandFromStdin) {
child.stdin?.on("error", () => {});
child.stdin?.end(command);
}
} catch (error) {
const cause = toError(error);
settle(err(new ExecutionError("spawn_error", cause.message, cause)));

View File

@@ -1,4 +1,4 @@
import type { ImageContent, Message, TextContent } from "@earendil-works/pi-ai";
import type { ImageContent, Message, TextContent } from "@earendil-works/pi-ai/base";
import type { AgentMessage } from "../types.ts";
export const COMPACTION_SUMMARY_PREFIX = `The conversation history before this point was compacted into the following summary:

View File

@@ -1,7 +1,8 @@
import type { ImageContent, TextContent } from "@earendil-works/pi-ai";
import type { ImageContent, TextContent } from "@earendil-works/pi-ai/base";
import type { AgentMessage } from "../../types.ts";
import { createBranchSummaryMessage, createCompactionSummaryMessage, createCustomMessage } from "../messages.ts";
import type {
ActiveToolsChangeEntry,
BranchSummaryEntry,
CompactionEntry,
CustomEntry,
@@ -21,6 +22,7 @@ import { SessionError } from "../types.ts";
export function buildSessionContext(pathEntries: SessionTreeEntry[]): SessionContext {
let thinkingLevel = "off";
let model: { provider: string; modelId: string } | null = null;
let activeToolNames: string[] | null = null;
let compaction: CompactionEntry | null = null;
for (const entry of pathEntries) {
@@ -30,6 +32,8 @@ export function buildSessionContext(pathEntries: SessionTreeEntry[]): SessionCon
model = { provider: entry.provider, modelId: entry.modelId };
} else if (entry.type === "message" && entry.message.role === "assistant") {
model = { provider: entry.message.provider, modelId: entry.message.model };
} else if (entry.type === "active_tools_change") {
activeToolNames = [...entry.activeToolNames];
} else if (entry.type === "compaction") {
compaction = entry;
}
@@ -72,7 +76,7 @@ export function buildSessionContext(pathEntries: SessionTreeEntry[]): SessionCon
}
}
return { messages, thinkingLevel, model };
return { messages, thinkingLevel, model, activeToolNames };
}
export class Session<TMetadata extends SessionMetadata = SessionMetadata> {
@@ -156,6 +160,16 @@ export class Session<TMetadata extends SessionMetadata = SessionMetadata> {
} satisfies ModelChangeEntry);
}
async appendActiveToolsChange(activeToolNames: string[]): Promise<string> {
return this.appendTypedEntry({
type: "active_tools_change",
id: await this.storage.createEntryId(),
parentId: await this.storage.getLeafId(),
timestamp: new Date().toISOString(),
activeToolNames: [...activeToolNames],
} satisfies ActiveToolsChangeEntry);
}
async appendCompaction<T = unknown>(
summary: string,
firstKeptEntryId: string,

View File

@@ -1,5 +1,5 @@
import type { ImageContent, Model, SimpleStreamOptions, TextContent, Transport } from "@earendil-works/pi-ai";
import type { AgentEvent, AgentMessage, AgentTool, QueueMode, ThinkingLevel } from "../index.ts";
import type { ImageContent, Model, SimpleStreamOptions, TextContent, Transport } from "@earendil-works/pi-ai/base";
import type { AgentEvent, AgentMessage, AgentTool, QueueMode, ThinkingLevel } from "../types.ts";
import type { Session } from "./session/session.ts";
/** Result of a fallible operation. Expected failures are returned as `ok: false` instead of thrown. */
@@ -354,6 +354,11 @@ export interface ModelChangeEntry extends SessionTreeEntryBase {
modelId: string;
}
export interface ActiveToolsChangeEntry extends SessionTreeEntryBase {
type: "active_tools_change";
activeToolNames: string[];
}
export interface CompactionEntry<T = unknown> extends SessionTreeEntryBase {
type: "compaction";
summary: string;
@@ -405,6 +410,7 @@ export type SessionTreeEntry =
| MessageEntry
| ThinkingLevelChangeEntry
| ModelChangeEntry
| ActiveToolsChangeEntry
| CompactionEntry
| BranchSummaryEntry
| CustomEntry
@@ -417,6 +423,7 @@ export interface SessionContext {
messages: AgentMessage[];
thinkingLevel: string;
model: { provider: string; modelId: string } | null;
activeToolNames: string[] | null;
}
export interface SessionMetadata {
@@ -593,19 +600,28 @@ export interface SessionTreeEvent {
fromHook?: boolean;
}
export interface ModelSelectEvent {
type: "model_select";
export interface ModelUpdateEvent {
type: "model_update";
model: Model<any>;
previousModel: Model<any> | undefined;
source: "set" | "restore";
}
export interface ThinkingLevelSelectEvent {
type: "thinking_level_select";
export interface ThinkingLevelUpdateEvent {
type: "thinking_level_update";
level: ThinkingLevel;
previousLevel: ThinkingLevel;
}
export interface ToolsUpdateEvent {
type: "tools_update";
toolNames: string[];
previousToolNames: string[];
activeToolNames: string[];
previousActiveToolNames: string[];
source: "set" | "restore";
}
export interface ResourcesUpdateEvent<
TSkill extends Skill = Skill,
TPromptTemplate extends PromptTemplate = PromptTemplate,
@@ -634,9 +650,10 @@ export type AgentHarnessOwnEvent<
| SessionCompactEvent
| SessionBeforeTreeEvent
| SessionTreeEvent
| ModelSelectEvent
| ThinkingLevelSelectEvent
| ResourcesUpdateEvent<TSkill, TPromptTemplate>;
| ModelUpdateEvent
| ThinkingLevelUpdateEvent
| ResourcesUpdateEvent<TSkill, TPromptTemplate>
| ToolsUpdateEvent;
export type AgentHarnessEvent<TSkill extends Skill = Skill, TPromptTemplate extends PromptTemplate = PromptTemplate> =
| AgentEvent
@@ -696,9 +713,10 @@ export type AgentHarnessEventResultMap = {
session_compact: undefined;
session_before_tree: SessionBeforeTreeResult | undefined;
session_tree: undefined;
model_select: undefined;
thinking_level_select: undefined;
model_update: undefined;
thinking_level_update: undefined;
resources_update: undefined;
tools_update: undefined;
queue_update: undefined;
save_point: undefined;
abort: undefined;

View File

@@ -1,44 +1,5 @@
// Core Agent
export * from "./agent.ts";
// Loop functions
export * from "./agent-loop.ts";
export * from "./harness/agent-harness.ts";
export {
type BranchPreparation,
type BranchSummaryDetails,
type CollectEntriesResult,
collectEntriesForBranchSummary,
generateBranchSummary,
prepareBranchEntries,
} from "./harness/compaction/branch-summarization.ts";
export {
calculateContextTokens,
compact,
DEFAULT_COMPACTION_SETTINGS,
estimateContextTokens,
estimateTokens,
findCutPoint,
findTurnStartIndex,
generateSummary,
getLastAssistantUsage,
prepareCompaction,
serializeConversation,
shouldCompact,
} from "./harness/compaction/compaction.ts";
export * from "./harness/messages.ts";
export * from "./harness/prompt-templates.ts";
export * from "./harness/session/jsonl-repo.ts";
export * from "./harness/session/memory-repo.ts";
export * from "./harness/session/repo-utils.ts";
export * from "./harness/session/session.ts";
export { uuidv7 } from "./harness/session/uuid.ts";
export * from "./harness/skills.ts";
export * from "./harness/system-prompt.ts";
// Harness
export * from "./harness/types.ts";
export * from "./harness/utils/shell-output.ts";
export * from "./harness/utils/truncate.ts";
// Proxy utilities
export * from "./proxy.ts";
// Types
export * from "./types.ts";
// Import the default pi-ai entrypoint so that all built-in providers register
// automatically. Unlike "@earendil-works/pi-ai/base" which does not.
import "@earendil-works/pi-ai";
export * from "./base.ts";

View File

@@ -14,7 +14,7 @@ import {
type SimpleStreamOptions,
type StopReason,
type ToolCall,
} from "@earendil-works/pi-ai";
} from "@earendil-works/pi-ai/base";
// Create stream class matching ProxyMessageEventStream
class ProxyMessageEventStream extends EventStream<AssistantMessageEvent, AssistantMessage> {

View File

@@ -9,7 +9,7 @@ import type {
TextContent,
Tool,
ToolResultMessage,
} from "@earendil-works/pi-ai";
} from "@earendil-works/pi-ai/base";
import type { Static, TSchema } from "typebox";
/**
@@ -354,7 +354,12 @@ export interface AgentToolResult<T> {
terminate?: boolean;
}
/** Callback used by tools to stream partial execution updates. */
/**
* Callback used by tools to stream partial execution updates.
*
* The callback is scoped to the current `execute()` invocation. Calls made after
* the tool promise settles are ignored.
*/
export type AgentToolUpdateCallback<T = any> = (partialResult: AgentToolResult<T>) => void;
/** Tool definition used by the agent runtime. */

View File

@@ -1,6 +1,7 @@
import { type AssistantMessage, type AssistantMessageEvent, EventStream, getModel } from "@earendil-works/pi-ai";
import { Type } from "typebox";
import { describe, expect, it } from "vitest";
import { Agent } from "../src/index.ts";
import { Agent, type AgentEvent, type AgentTool, type AgentToolUpdateCallback } from "../src/index.ts";
// Mock stream that mimics AssistantMessageEventStream
class MockAssistantStream extends EventStream<AssistantMessageEvent, AssistantMessage> {
@@ -36,6 +37,28 @@ function createAssistantMessage(text: string): AssistantMessage {
};
}
type ToolCallContent = Extract<AssistantMessage["content"][number], { type: "toolCall" }>;
function createAssistantToolUseMessage(content: ToolCallContent[]): AssistantMessage {
return {
role: "assistant",
content,
api: "openai-responses",
provider: "openai",
model: "mock",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "toolUse",
timestamp: Date.now(),
};
}
function createDeferred(): {
promise: Promise<void>;
resolve: () => void;
@@ -242,6 +265,147 @@ describe("Agent", () => {
expect(receivedSignal?.aborted).toBe(true);
});
it("should ignore tool updates after the tool execution settles", async () => {
const toolSchema = Type.Object({});
let delayedUpdate: AgentToolUpdateCallback<{ status: string }> | undefined;
const events: AgentEvent[] = [];
const unhandledRejections: unknown[] = [];
const onUnhandledRejection = (error: unknown) => {
unhandledRejections.push(error);
};
const tool: AgentTool<typeof toolSchema, { status: string }> = {
name: "delayed_tool",
label: "Delayed Tool",
description: "Captures progress callbacks",
parameters: toolSchema,
async execute(_toolCallId, _params, _signal, onUpdate) {
delayedUpdate = onUpdate;
onUpdate?.({
content: [{ type: "text", text: "running" }],
details: { status: "running" },
});
return {
content: [{ type: "text", text: "ok" }],
details: { status: "done" },
terminate: true,
};
},
};
const agent = new Agent({
initialState: { tools: [tool] },
streamFn: () => {
const stream = new MockAssistantStream();
queueMicrotask(() => {
stream.push({
type: "done",
reason: "toolUse",
message: createAssistantToolUseMessage([
{ type: "toolCall", id: "call-1", name: "delayed_tool", arguments: {} },
]),
});
});
return stream;
},
});
agent.subscribe((event) => {
events.push(event);
});
process.on("unhandledRejection", onUnhandledRejection);
try {
await agent.prompt("run tool");
const eventCountAfterPrompt = events.length;
delayedUpdate?.({
content: [{ type: "text", text: "late" }],
details: { status: "late" },
});
await new Promise((resolve) => setTimeout(resolve, 0));
expect(events.filter((event) => event.type === "tool_execution_update")).toHaveLength(1);
expect(events).toHaveLength(eventCountAfterPrompt);
expect(unhandledRejections).toEqual([]);
} finally {
process.off("unhandledRejection", onUnhandledRejection);
}
});
it("should ignore a settled parallel tool update while another tool is still running", async () => {
const toolSchema = Type.Object({});
const slowStarted = createDeferred();
const settledToolEnded = createDeferred();
const releaseSlow = createDeferred();
let settledToolUpdate: AgentToolUpdateCallback<{ status: string }> | undefined;
const events: AgentEvent[] = [];
const settledTool: AgentTool<typeof toolSchema, { status: string }> = {
name: "settled_tool",
label: "Settled Tool",
description: "Captures progress callbacks",
parameters: toolSchema,
async execute(_toolCallId, _params, _signal, onUpdate) {
settledToolUpdate = onUpdate;
return {
content: [{ type: "text", text: "done" }],
details: { status: "done" },
terminate: true,
};
},
};
const slowTool: AgentTool<typeof toolSchema, { status: string }> = {
name: "slow_tool",
label: "Slow Tool",
description: "Keeps the agent run active",
parameters: toolSchema,
async execute() {
slowStarted.resolve();
await releaseSlow.promise;
return {
content: [{ type: "text", text: "done" }],
details: { status: "done" },
terminate: true,
};
},
};
const agent = new Agent({
initialState: { tools: [settledTool, slowTool] },
streamFn: () => {
const stream = new MockAssistantStream();
queueMicrotask(() => {
stream.push({
type: "done",
reason: "toolUse",
message: createAssistantToolUseMessage([
{ type: "toolCall", id: "call-1", name: "settled_tool", arguments: {} },
{ type: "toolCall", id: "call-2", name: "slow_tool", arguments: {} },
]),
});
});
return stream;
},
});
agent.subscribe((event) => {
events.push(event);
if (event.type === "tool_execution_end" && event.toolCallId === "call-1") {
settledToolEnded.resolve();
}
});
const promptPromise = agent.prompt("run tools");
await Promise.all([slowStarted.promise, settledToolEnded.promise]);
const eventCountBeforeLateUpdate = events.length;
settledToolUpdate?.({
content: [{ type: "text", text: "late" }],
details: { status: "late" },
});
await new Promise((resolve) => setTimeout(resolve, 0));
expect(events).toHaveLength(eventCountBeforeLateUpdate);
releaseSlow.resolve();
await promptPromise;
expect(events.filter((event) => event.type === "tool_execution_update")).toHaveLength(0);
});
it("should update state with mutators", () => {
const agent = new Agent();

View File

@@ -17,10 +17,6 @@ interface AppPromptTemplate extends PromptTemplate {
source: "project" | "user";
}
interface AppTool extends AgentTool {
source: "builtin" | "extension";
}
const registrations: Array<{ unregister(): void }> = [];
function textFromUserMessages(messages: Array<{ role: string; content: unknown }>): string[] {
@@ -458,11 +454,111 @@ describe("AgentHarness", () => {
});
});
it("preserves app tool types for getters and update events", async () => {
const session = new Session(new InMemorySessionStorage());
const env = new NodeExecutionEnv({ cwd: process.cwd() });
const model = getModel("anthropic", "claude-sonnet-4-5");
type AppTool = AgentTool<typeof calculateTool.parameters, undefined> & { source: "builtin" | "extension" };
const inspectTool: AppTool = { ...calculateTool, name: "inspect", source: "builtin" };
const searchTool: AppTool = { ...calculateTool, name: "search", source: "extension" };
const harness = new AgentHarness<AppSkill, AppPromptTemplate, AppTool>({
env,
session,
model,
tools: [inspectTool, searchTool],
activeToolNames: ["inspect"],
});
const updates: Array<{
toolNames: string[];
previousToolNames: string[];
activeToolNames: string[];
previousActiveToolNames: string[];
source: "set" | "restore";
}> = [];
harness.subscribe((event) => {
if (event.type === "tools_update") {
updates.push({
toolNames: event.toolNames,
previousToolNames: event.previousToolNames,
activeToolNames: event.activeToolNames,
previousActiveToolNames: event.previousActiveToolNames,
source: event.source,
});
expect(harness.getActiveTools().map((tool) => tool.name)).toEqual(event.activeToolNames);
}
});
const tools = harness.getTools();
const activeTools = harness.getActiveTools();
tools.pop();
activeTools.pop();
expect(harness.getTools().map((tool) => tool.name)).toEqual(["inspect", "search"]);
expect(harness.getActiveTools().map((tool) => tool.source)).toEqual(["builtin"]);
await harness.setActiveTools(["search"]);
await harness.setTools([searchTool], ["search"]);
await expect(harness.setActiveTools(["missing"])).rejects.toMatchObject({ code: "invalid_argument" });
await expect(harness.setActiveTools(["search", "search"])).rejects.toMatchObject({ code: "invalid_argument" });
await expect(harness.setTools([inspectTool])).rejects.toMatchObject({ code: "invalid_argument" });
await expect(harness.setTools([inspectTool, inspectTool], ["inspect"])).rejects.toMatchObject({
code: "invalid_argument",
});
expect(updates).toEqual([
{
toolNames: ["inspect", "search"],
previousToolNames: ["inspect", "search"],
activeToolNames: ["search"],
previousActiveToolNames: ["inspect"],
source: "set",
},
{
toolNames: ["search"],
previousToolNames: ["inspect", "search"],
activeToolNames: ["search"],
previousActiveToolNames: ["search"],
source: "set",
},
]);
expect(harness.getTools().map((tool) => tool.source)).toEqual(["extension"]);
expect(harness.getActiveTools().map((tool) => tool.name)).toEqual(["search"]);
expect((await session.buildContext()).activeToolNames).toEqual(["search"]);
});
it("validates constructor tool names", () => {
const session = new Session(new InMemorySessionStorage());
const env = new NodeExecutionEnv({ cwd: process.cwd() });
const model = getModel("anthropic", "claude-sonnet-4-5");
expect(
() => new AgentHarness({ env, session, model, tools: [calculateTool], activeToolNames: ["missing"] }),
).toThrow(/Unknown tool/);
expect(
() =>
new AgentHarness({
env,
session,
model,
tools: [calculateTool, calculateTool],
activeToolNames: [calculateTool.name],
}),
).toThrow(/Duplicate tool/);
expect(
() =>
new AgentHarness({
env,
session,
model,
tools: [calculateTool],
activeToolNames: [calculateTool.name, calculateTool.name],
}),
).toThrow(/Duplicate active tool/);
});
it("preserves app resource types for getters and update events", async () => {
const session = new Session(new InMemorySessionStorage());
const env = new NodeExecutionEnv({ cwd: process.cwd() });
const model = getModel("anthropic", "claude-sonnet-4-5");
const harness = new AgentHarness<AppSkill, AppPromptTemplate, AppTool>({ env, session, model });
const harness = new AgentHarness<AppSkill, AppPromptTemplate, AgentTool>({ env, session, model });
const skill: AppSkill = {
name: "inspect",
description: "Inspect things",

View File

@@ -1,5 +1,5 @@
import { access, chmod, realpath, symlink } from "node:fs/promises";
import { join } from "node:path";
import { delimiter, join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { NodeExecutionEnv } from "../../src/harness/env/nodejs.ts";
import { FileError, getOrThrow } from "../../src/harness/types.ts";
@@ -201,6 +201,39 @@ describe("NodeExecutionEnv", () => {
expect(result).toEqual({ stdout: `${await realpath(root)}:ok`, stderr: "", exitCode: 0 });
});
it("uses stdin command transport for legacy WSL bash paths", async () => {
if (process.platform === "win32") return;
const root = createTempDir();
const shellPath = "C:\\Windows\\System32\\bash.exe";
const env = new NodeExecutionEnv({ cwd: root });
getOrThrow(await env.writeFile(shellPath, '#!/bin/sh\nprintf \'args:%s\\n\' "$*" >&2\nexec /bin/bash "$@"\n'));
await chmod(join(root, shellPath), 0o755);
const originalCwd = process.cwd();
const originalPath = process.env.PATH;
const platformDescriptor = Object.getOwnPropertyDescriptor(process, "platform");
try {
process.chdir(root);
process.env.PATH = `${root}${delimiter}${originalPath ?? ""}`;
Object.defineProperty(process, "platform", {
configurable: true,
value: "win32",
});
const wslEnv = new NodeExecutionEnv({ cwd: root, shellPath });
const nameExpansion = "$" + "{name}";
const result = getOrThrow(await wslEnv.exec(`name='World'; echo "Hello, ${nameExpansion}!"`));
expect(result).toEqual({ stdout: "Hello, World!\n", stderr: "args:-s\n", exitCode: 0 });
} finally {
process.chdir(originalCwd);
process.env.PATH = originalPath;
if (platformDescriptor) {
Object.defineProperty(process, "platform", platformDescriptor);
}
}
});
it("streams stdout and stderr chunks", async () => {
const root = createTempDir();
const env = new NodeExecutionEnv({ cwd: root });

View File

@@ -2,6 +2,10 @@
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"paths": {
"@earendil-works/pi-ai": ["../ai/dist/index.d.ts"],
"@earendil-works/pi-ai/*": ["../ai/dist/*.d.ts", "../ai/dist/providers/*.d.ts"]
},
"rootDir": "./src"
},
"include": ["src/**/*.ts"],

View File

@@ -1,6 +1,12 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
resolve: {
alias: {
"@earendil-works/pi-ai/base": new URL("../ai/src/base.ts", import.meta.url).pathname,
"@earendil-works/pi-ai": new URL("../ai/src/index.ts", import.meta.url).pathname,
},
},
test: {
globals: true,
environment: "node",

View File

@@ -1,6 +1,12 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
resolve: {
alias: {
"@earendil-works/pi-ai/base": new URL("../ai/src/base.ts", import.meta.url).pathname,
"@earendil-works/pi-ai": new URL("../ai/src/index.ts", import.meta.url).pathname,
},
},
test: {
globals: true,
environment: "node",