import { execSync } from "node:child_process";
import {
createAgentSession,
DefaultResourceLoader,
SessionManager,
SettingsManager,
getAgentDir,
} from "@earendil-works/pi-coding-agent";
import type { AgentSession, AgentSessionEvent, ToolDefinition } from "@earendil-works/pi-coding-agent";
import type {
AgentDefinition,
EnvInfo,
RunOptions,
RunResult,
ToolActivity,
} from "../shared/types.js";
import { preloadSkills } from "./skill-loader.js";
import { evaluateToolCall } from "./tool-budget.js";
import { buildMemoryInjection } from "./memory.js";
interface SkillBlock {
name: string;
content: string;
}
/**
* Detect environment info for prompt construction.
*/
export function detectEnv(cwd: string): EnvInfo {
let isGitRepo = false;
let branch = "";
try {
execSync("git rev-parse --is-inside-work-tree", { cwd, stdio: "pipe" });
isGitRepo = true;
branch = execSync("git rev-parse --abbrev-ref HEAD", {
cwd,
stdio: "pipe",
})
.toString()
.trim();
} catch {
// Not a git repo or git not available
}
return { isGitRepo, branch, platform: process.platform };
}
/**
* Build the system prompt for an agent session.
*
* - `"replace"` (default): Agent owns its entire system prompt.
* - `"append"`: Agent inherits parent prompt and layers specialization.
*/
export function buildAgentPrompt(
agentDef: AgentDefinition,
cwd: string,
env: EnvInfo,
parentSystemPrompt?: string,
skillBlocks?: SkillBlock[],
): string {
if (agentDef.promptMode === "append") {
return buildAppendPrompt(agentDef, cwd, env, parentSystemPrompt, skillBlocks);
}
return buildReplacePrompt(agentDef, cwd, env, skillBlocks);
}
function buildReplacePrompt(
agentDef: AgentDefinition,
cwd: string,
env: EnvInfo,
skillBlocks?: SkillBlock[],
): string {
const envLine = `Environment: cwd=${cwd}${env.isGitRepo ? `, git branch=${env.branch}` : ""}, platform=${env.platform}`;
const parts: string[] = [
``,
"",
envLine,
];
if (agentDef.systemPrompt.trim()) {
parts.push("", agentDef.systemPrompt.trim());
}
appendSkillBlocks(parts, skillBlocks);
return parts.join("\n");
}
function buildAppendPrompt(
agentDef: AgentDefinition,
cwd: string,
env: EnvInfo,
parentSystemPrompt?: string,
skillBlocks?: SkillBlock[],
): string {
const base =
parentSystemPrompt?.trim() || "You are a general-purpose coding agent.";
const envLine = `Environment: cwd=${cwd}${env.isGitRepo ? `, git branch=${env.branch}` : ""}, platform=${env.platform}`;
const parts: string[] = [
base,
"",
"",
"You are operating as a specialized sub-agent. Your parent session has",
"delegated a specific task to you. Focus on completing the delegated",
"task efficiently.",
"",
"",
``,
envLine,
];
if (agentDef.systemPrompt.trim()) {
parts.push(
"",
"",
agentDef.systemPrompt.trim(),
"",
);
}
appendSkillBlocks(parts, skillBlocks);
return parts.join("\n");
}
function appendSkillBlocks(parts: string[], skillBlocks?: SkillBlock[]): void {
if (skillBlocks && skillBlocks.length > 0) {
for (const skill of skillBlocks) {
parts.push("", `\n${skill.content}\n`);
}
}
}
/**
* Build a formatted string of the parent conversation history for context forking.
*/
export function buildParentContext(entries: unknown[]): string {
const lines: string[] = [];
for (const entry of entries) {
const e = entry as {
type?: string;
role?: string;
content?: Array<{ type: string; text?: string }>;
summary?: string;
};
if (e.type === "message" && e.role === "user") {
const text =
e.content
?.filter((c) => c.type === "text")
.map((c) => c.text ?? "")
.join("") ?? "";
if (text) lines.push(`[User]: ${text}`);
} else if (e.type === "message" && e.role === "assistant") {
const text =
e.content
?.filter((c) => c.type === "text")
.map((c) => c.text ?? "")
.join("") ?? "";
if (text) lines.push(`[Assistant]: ${text}`);
} else if (e.type === "compaction") {
if (e.summary) lines.push(`[Summary]: ${e.summary}`);
}
// Skip toolResult entries and anything else
}
return [
"",
"The following is the conversation history from the parent session that",
"delegated this task to you. Use it for context but focus on your",
"assigned task.",
"",
...lines,
"",
].join("\n");
}
/**
* Stateless session execution. Creates an AgentSession, subscribes to events,
* executes the prompt, and returns the result.
*/
export async function runAgent(
agentDef: AgentDefinition,
options: RunOptions,
ctx: {
model?: unknown;
modelRegistry?: unknown;
sessionManager?: { getBranch?: () => unknown[] };
},
): Promise {
// 1. Resolve tools — exclude "subagent" unless recursion is allowed, then filter disallowed
const allowedTools = (
options.allowRecursion
? agentDef.tools
: agentDef.tools.filter((t) => t !== "subagent")
).filter((t) => !(agentDef.disallowedTools ?? []).includes(t));
// 2. Build system prompt
const env = detectEnv(options.cwd);
const preloaded =
Array.isArray(agentDef.skills) && agentDef.skills.length > 0
? preloadSkills(agentDef.skills, options.cwd)
: [];
const skillBlocks = preloaded.length > 0 ? preloaded : undefined;
let systemPrompt = buildAgentPrompt(
agentDef,
options.cwd,
env,
options.parentSystemPrompt,
skillBlocks,
);
// Inject agent memory block (append position)
if (agentDef.memory) {
const hasWriteTools =
allowedTools.includes("write") || allowedTools.includes("edit");
const memoryBlock = buildMemoryInjection(
agentDef.memory,
options.cwd,
hasWriteTools,
);
if (memoryBlock) {
systemPrompt += `\n\n${memoryBlock}`;
}
}
// 2b. If inheritContext, prepend parent conversation to prompt
let fullPrompt = options.prompt;
if (options.inheritContext) {
const ctxWithSession = ctx as {
sessionManager?: { getBranch?: () => unknown[] };
};
if (ctxWithSession.sessionManager?.getBranch) {
const parentContext = buildParentContext(
ctxWithSession.sessionManager.getBranch(),
);
fullPrompt = `${parentContext}\n\n${options.prompt}`;
}
}
// 3. Create ResourceLoader with policy-driven extension loading.
// Extensions disabled when agent is isolated, or explicitly disables them.
// A string[] list (selective) or true (all) means extensions are enabled.
const agentDir = getAgentDir();
const noExtensions = agentDef.isolated === true || agentDef.extensions === false;
const loader = new DefaultResourceLoader({
cwd: options.cwd,
agentDir,
noExtensions,
noSkills: true,
noPromptTemplates: true,
noThemes: true,
noContextFiles: true,
systemPromptOverride: () => systemPrompt,
appendSystemPromptOverride: () => [],
});
await loader.reload();
// 4. Resolve model
const model = (options.model ?? ctx.model) as never;
// 5. Create session
const settingsManager = SettingsManager.create(options.cwd, agentDir);
const sessionManager = SessionManager.inMemory(options.cwd);
const thinkingLevel = options.thinking ?? agentDef.thinking;
const customTools = (options.customTools ?? []) as ToolDefinition[];
// Custom tool names must be in the allowed tools list, otherwise
// createAgentSession's internal allowlist filter silently drops them.
const effectiveAllowedTools = [...allowedTools, ...customTools.map((t) => t.name)];
const { session } = await createAgentSession({
cwd: options.cwd,
agentDir,
sessionManager,
settingsManager,
model,
tools: effectiveAllowedTools,
resourceLoader: loader,
...(thinkingLevel ? { thinkingLevel: thinkingLevel as never } : {}),
...(customTools.length > 0 ? { customTools } : {}),
});
// 6. Bind extensions (required even when empty)
await session.bindExtensions({});
options.onSessionCreated?.(session);
// 7. Subscribe to events + turn-based limits
let responseText = "";
let turnCount = 0;
let aborted = false;
let steered = false;
let budgetToolCount = 0;
let budgetSoftNudged = false;
const maxTurns = options.maxTurns ?? 0;
const graceTurns = options.graceTurns ?? 5;
const unsubscribe = session.subscribe((event: AgentSessionEvent) => {
if (event.type === "message_start") {
responseText = "";
}
if (
event.type === "message_update" &&
event.assistantMessageEvent.type === "text_delta"
) {
responseText += event.assistantMessageEvent.delta;
options.onTextDelta?.(event.assistantMessageEvent.delta, responseText);
}
if (event.type === "tool_execution_start") {
options.onToolActivity?.({ type: "start", toolName: event.toolName });
if (options.toolBudget) {
budgetToolCount++;
const budgetResult = evaluateToolCall(
options.toolBudget,
budgetToolCount,
event.toolName,
);
if (budgetResult.outcome === "soft-reached" && !budgetSoftNudged) {
budgetSoftNudged = true;
session.steer(budgetResult.message ?? "Tool budget soft limit reached.");
steered = true;
} else if (budgetResult.outcome === "hard-blocked") {
session.steer(budgetResult.message ?? "Tool budget hard limit reached.");
aborted = true;
session.abort();
}
}
}
if (event.type === "tool_execution_end") {
options.onToolActivity?.({ type: "end", toolName: event.toolName });
}
if (event.type === "turn_end") {
turnCount++;
options.onTurnEnd?.(turnCount);
// Soft limit: steer the agent to wrap up
if (maxTurns > 0 && turnCount === maxTurns && !steered) {
session.steer(
"You have reached the turn limit. Wrap up your work immediately and return your final result.",
);
steered = true;
}
// Hard limit: abort after grace period
if (maxTurns > 0 && turnCount >= maxTurns + graceTurns) {
aborted = true;
session.abort();
}
}
if (event.type === "message_end" && event.message.role === "assistant") {
const usage = (
event.message as {
usage?: { input?: number; output?: number; cacheWrite?: number };
}
).usage;
if (usage) {
options.onUsage?.({
input: usage.input ?? 0,
output: usage.output ?? 0,
cacheWrite: usage.cacheWrite ?? 0,
});
}
}
if (event.type === "agent_settled") {
options.onSettled?.();
}
});
// 8. Wire parent abort signal
const cleanupAbort = forwardAbortSignal(session, options.signal);
// 9. Execute prompt (use fullPrompt which may include parent context)
try {
await session.prompt(fullPrompt);
} catch (error) {
if (!aborted && !options.signal?.aborted) throw error;
aborted = true;
} finally {
unsubscribe();
cleanupAbort();
}
// 10. Fallback: get text from session messages if streaming didn't capture it
if (!responseText.trim()) {
responseText = getLastAssistantText(session);
}
return { responseText, session: session as unknown, aborted, steered };
}
/**
* Resume an existing session with a new prompt.
* Reuses forwardAbortSignal and getLastAssistantText helpers.
*/
export async function resumeAgent(
session: AgentSession,
prompt: string,
options: {
onToolActivity?: (activity: ToolActivity) => void;
onAssistantUsage?: (usage: { input: number; output: number; cacheWrite: number }) => void;
onTextDelta?: (delta: string, fullText: string) => void;
onTurnEnd?: () => void;
onSettled?: () => void;
signal?: AbortSignal;
} = {},
): Promise {
let responseText = "";
const cleanupAbort = forwardAbortSignal(session, options.signal);
const unsubscribe = session.subscribe((event: AgentSessionEvent) => {
if (event.type === "message_start") responseText = "";
if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
responseText += event.assistantMessageEvent.delta;
options.onTextDelta?.(event.assistantMessageEvent.delta, responseText);
}
if (event.type === "tool_execution_start") {
options.onToolActivity?.({ type: "start", toolName: event.toolName });
}
if (event.type === "tool_execution_end") {
options.onToolActivity?.({ type: "end", toolName: event.toolName });
}
if (event.type === "message_end" && event.message.role === "assistant") {
const usage = (event.message as { usage?: { input?: number; output?: number; cacheWrite?: number } }).usage;
if (usage) {
options.onAssistantUsage?.({
input: usage.input ?? 0,
output: usage.output ?? 0,
cacheWrite: usage.cacheWrite ?? 0,
});
}
}
if (event.type === "turn_end") {
options.onTurnEnd?.();
}
if (event.type === "agent_settled") {
options.onSettled?.();
}
});
try {
await session.prompt(prompt);
} finally {
unsubscribe();
cleanupAbort();
}
return responseText.trim() || getLastAssistantText(session);
}
/** Extract readable conversation text from a session for verbose output. */
export function getAgentConversation(session: unknown): string {
const s = session as { messages?: Array<{ role: string; content?: unknown }> };
if (!s.messages) return "";
const lines: string[] = [];
for (const msg of s.messages) {
if (msg.role === "assistant") {
const content = msg.content as Array<{ type: string; text?: string }> | undefined;
if (content) {
const text = content.filter(b => b.type === "text").map(b => b.text ?? "").join("");
if (text.trim()) lines.push(`[assistant] ${text.trim()}`);
}
} else if (msg.role === "user") {
const content = msg.content;
const text = typeof content === "string" ? content : Array.isArray(content)
? (content as Array<{ type: string; text?: string }>).filter(b => b.type === "text").map(b => b.text ?? "").join("")
: "";
if (text.trim()) lines.push(`[user] ${text.trim()}`);
}
}
return lines.join("\n\n");
}
/** Wire an AbortSignal to abort a session. Returns cleanup function. */
function forwardAbortSignal(
session: AgentSession,
signal?: AbortSignal,
): () => void {
if (!signal) return () => {};
if (signal.aborted) {
session.abort();
return () => {};
}
const onAbort = () => {
session.abort();
};
signal.addEventListener("abort", onAbort, { once: true });
return () => signal.removeEventListener("abort", onAbort);
}
/** Get last assistant text from session transcript (fallback when streaming missed it). */
function getLastAssistantText(session: AgentSession): string {
const messages = session.messages;
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i];
if (msg.role !== "assistant") continue;
const content = (
msg as { content?: Array<{ type: string; text?: string }> }
).content;
if (!content) continue;
const text = content
.filter((block) => block.type === "text")
.map((block) => block.text ?? "")
.join("");
if (text.trim()) return text.trim();
}
return "";
}