@earendil-works/pi-coding-agent
Version:
Coding agent CLI with read, bash, edit, write tools and session management
267 lines • 12.3 kB
JavaScript
import { join } from "node:path";
import { Agent, setDefaultStreamFn } from "@earendil-works/pi-agent-core";
import { clampThinkingLevel, streamSimple } from "@earendil-works/pi-ai/compat";
import { getAgentDir } from "../config.js";
import { resolvePath } from "../utils/paths.js";
import { AgentSession } from "./agent-session.js";
import { formatNoModelsAvailableMessage } from "./auth-guidance.js";
import { DEFAULT_THINKING_LEVEL } from "./defaults.js";
import { convertToLlm } from "./messages.js";
import { findInitialModel } from "./model-resolver.js";
import { ModelRuntime } from "./model-runtime.js";
import { mergeProviderAttributionHeaders } from "./provider-attribution.js";
import { DefaultResourceLoader } from "./resource-loader.js";
import { getDefaultSessionDir, SessionManager } from "./session-manager.js";
import { SettingsManager } from "./settings-manager.js";
import { time } from "./timings.js";
import { createBashTool, createCodingTools, createEditTool, createFindTool, createGrepTool, createLsTool, createReadOnlyTools, createReadTool, createWriteTool, withFileMutationQueue, } from "./tools/index.js";
// Preserve the pre-0.81 fallback for extensions that construct Agent instances
// or invoke low-level agent loops without supplying streamFn. Agent core remains
// provider-agnostic and does not import pi-ai/compat itself.
setDefaultStreamFn(streamSimple);
// Re-exports
export * from "./agent-session-runtime.js";
export { withFileMutationQueue,
// Tool factories (for custom cwd)
createCodingTools, createReadOnlyTools, createReadTool, createBashTool, createEditTool, createWriteTool, createGrepTool, createFindTool, createLsTool, };
// Helper Functions
function getDefaultAgentDir() {
return getAgentDir();
}
/**
* Create an AgentSession with the specified options.
*
* @example
* ```typescript
* // Minimal - uses defaults
* const { session } = await createAgentSession();
*
* // With explicit model
* import { getModel } from '@earendil-works/pi-ai';
* const { session } = await createAgentSession({
* model: getModel('anthropic', 'claude-opus-4-5'),
* thinkingLevel: 'high',
* });
*
* // Continue previous session
* const { session, modelFallbackMessage } = await createAgentSession({
* continueSession: true,
* });
*
* // Full control
* const loader = new DefaultResourceLoader({
* cwd: process.cwd(),
* agentDir: getAgentDir(),
* settingsManager: SettingsManager.create(),
* });
* await loader.reload();
* const { session } = await createAgentSession({
* model: myModel,
* tools: ["read", "bash"],
* resourceLoader: loader,
* sessionManager: SessionManager.inMemory(),
* });
* ```
*/
export async function createAgentSession(options = {}) {
const cwd = resolvePath(options.cwd ?? options.sessionManager?.getCwd() ?? process.cwd());
const agentDir = options.agentDir ? resolvePath(options.agentDir) : getDefaultAgentDir();
let resourceLoader = options.resourceLoader;
const authPath = options.agentDir ? join(agentDir, "auth.json") : undefined;
const modelsPath = options.agentDir ? join(agentDir, "models.json") : undefined;
const modelRuntime = options.modelRuntime ?? (await ModelRuntime.create({ authPath, modelsPath }));
const settingsManager = options.settingsManager ?? SettingsManager.create(cwd, agentDir);
const sessionManager = options.sessionManager ?? SessionManager.create(cwd, getDefaultSessionDir(cwd, agentDir));
if (!resourceLoader) {
resourceLoader = new DefaultResourceLoader({ cwd, agentDir, settingsManager });
await resourceLoader.reload();
time("resourceLoader.reload");
}
// Check if session has existing data to restore
const existingSession = sessionManager.buildSessionContext();
const hasExistingSession = existingSession.messages.length > 0;
const hasThinkingEntry = sessionManager.getBranch().some((entry) => entry.type === "thinking_level_change");
let model = options.model;
let modelFallbackMessage;
// If session has data, try to restore model from it
if (!model && hasExistingSession && existingSession.model) {
const restoredModel = modelRuntime.getModel(existingSession.model.provider, existingSession.model.modelId);
if (restoredModel && modelRuntime.hasConfiguredAuth(restoredModel.provider)) {
model = restoredModel;
}
if (!model) {
modelFallbackMessage = `Could not restore model ${existingSession.model.provider}/${existingSession.model.modelId}`;
}
}
// If still no model, use findInitialModel (checks settings default, then provider defaults)
if (!model) {
const result = await findInitialModel({
scopedModels: [],
isContinuing: hasExistingSession,
defaultProvider: settingsManager.getDefaultProvider(),
defaultModelId: settingsManager.getDefaultModel(),
defaultThinkingLevel: settingsManager.getDefaultThinkingLevel(),
modelRuntime,
});
model = result.model;
if (!model) {
modelFallbackMessage = formatNoModelsAvailableMessage();
}
else if (modelFallbackMessage) {
modelFallbackMessage += `. Using ${model.provider}/${model.id}`;
}
}
let thinkingLevel = options.thinkingLevel;
// If session has data, restore thinking level from it
if (thinkingLevel === undefined && hasExistingSession) {
thinkingLevel = hasThinkingEntry
? existingSession.thinkingLevel
: (settingsManager.getDefaultThinkingLevel() ?? DEFAULT_THINKING_LEVEL);
}
// Fall back to settings default
if (thinkingLevel === undefined) {
thinkingLevel = settingsManager.getDefaultThinkingLevel() ?? DEFAULT_THINKING_LEVEL;
}
// Clamp to model capabilities
if (!model) {
thinkingLevel = "off";
}
else {
thinkingLevel = clampThinkingLevel(model, thinkingLevel);
}
const defaultActiveToolNames = ["read", "bash", "edit", "write"];
const allowedToolNames = options.tools ?? (options.noTools === "all" ? [] : undefined);
const excludedToolNames = options.excludeTools;
const excludedToolNameSet = excludedToolNames ? new Set(excludedToolNames) : undefined;
const initialActiveToolNames = (options.tools ? [...options.tools] : options.noTools ? [] : defaultActiveToolNames).filter((name) => !excludedToolNameSet?.has(name));
let agent;
// Create convertToLlm wrapper that filters images if blockImages is enabled (defense-in-depth)
const convertToLlmWithBlockImages = (messages) => {
const converted = convertToLlm(messages);
// Check setting dynamically so mid-session changes take effect
if (!settingsManager.getBlockImages()) {
return converted;
}
// Filter out ImageContent from all messages, replacing with text placeholder
return converted.map((msg) => {
if (msg.role === "user" || msg.role === "toolResult") {
const content = msg.content;
if (Array.isArray(content)) {
const hasImages = content.some((c) => c.type === "image");
if (hasImages) {
const filteredContent = content
.map((c) => c.type === "image" ? { type: "text", text: "Image reading is disabled." } : c)
.filter((c, i, arr) =>
// Dedupe consecutive "Image reading is disabled." texts
!(c.type === "text" &&
c.text === "Image reading is disabled." &&
i > 0 &&
arr[i - 1].type === "text" &&
arr[i - 1].text === "Image reading is disabled."));
return { ...msg, content: filteredContent };
}
}
}
return msg;
});
};
const extensionRunnerRef = {};
agent = new Agent({
initialState: {
systemPrompt: "",
model,
thinkingLevel,
tools: [],
},
convertToLlm: convertToLlmWithBlockImages,
streamFn: async (model, context, options) => {
const providerRetrySettings = settingsManager.getProviderRetrySettings();
const httpIdleTimeoutMs = settingsManager.getHttpIdleTimeoutMs();
// SDKs treat timeout=0 as 0ms (immediate timeout), not "no timeout".
// Use max int32 to effectively disable the timeout.
const effectiveTimeoutMs = httpIdleTimeoutMs === 0 ? 2147483647 : httpIdleTimeoutMs;
const timeoutMs = options?.timeoutMs ?? providerRetrySettings.timeoutMs ?? effectiveTimeoutMs;
const websocketConnectTimeoutMs = options?.websocketConnectTimeoutMs ?? settingsManager.getWebSocketConnectTimeoutMs();
const headerRunner = extensionRunnerRef.current;
return modelRuntime.streamSimple(model, context, {
...options,
timeoutMs,
websocketConnectTimeoutMs,
maxRetries: options?.maxRetries ?? providerRetrySettings.maxRetries,
maxRetryDelayMs: options?.maxRetryDelayMs ?? providerRetrySettings.maxRetryDelayMs,
transformHeaders: async (requestHeaders) => {
const headers = mergeProviderAttributionHeaders(model, settingsManager, options?.sessionId, requestHeaders);
return headerRunner?.hasHandlers("before_provider_headers")
? headerRunner.emitBeforeProviderHeaders(headers ?? {})
: (headers ?? {});
},
});
},
onPayload: async (payload, _model) => {
const runner = extensionRunnerRef.current;
if (!runner?.hasHandlers("before_provider_request")) {
return payload;
}
return runner.emitBeforeProviderRequest(payload);
},
onResponse: async (response, _model) => {
const runner = extensionRunnerRef.current;
if (!runner?.hasHandlers("after_provider_response")) {
return;
}
await runner.emit({
type: "after_provider_response",
status: response.status,
headers: response.headers,
});
},
sessionId: sessionManager.getSessionId(),
transformContext: async (messages) => {
const runner = extensionRunnerRef.current;
if (!runner)
return messages;
return runner.emitContext(messages);
},
steeringMode: settingsManager.getSteeringMode(),
followUpMode: settingsManager.getFollowUpMode(),
transport: settingsManager.getTransport(),
thinkingBudgets: settingsManager.getThinkingBudgets(),
maxRetryDelayMs: settingsManager.getProviderRetrySettings().maxRetryDelayMs,
});
// Restore messages if session has existing data
if (hasExistingSession) {
agent.state.messages = existingSession.messages;
if (!hasThinkingEntry) {
sessionManager.appendThinkingLevelChange(thinkingLevel);
}
}
else {
// Save initial model and thinking level for new sessions so they can be restored on resume
if (model) {
sessionManager.appendModelChange(model.provider, model.id);
}
sessionManager.appendThinkingLevelChange(thinkingLevel);
}
const session = new AgentSession({
agent,
sessionManager,
settingsManager,
cwd,
scopedModels: options.scopedModels,
resourceLoader,
customTools: options.customTools,
modelRuntime,
initialActiveToolNames,
allowedToolNames,
excludedToolNames,
extensionRunnerRef,
sessionStartEvent: options.sessionStartEvent,
});
const extensionsResult = resourceLoader.getExtensions();
return {
session,
extensionsResult,
modelFallbackMessage,
};
}
//# sourceMappingURL=sdk.js.map