返回源码地图

packages/coding-agent/src/core/sdk.ts

v1.0.0 · a13d35a742c6 · 06:SDK 组装工厂

完整原文供逐行核对;页面收录不代表每行都经过人工语义审核。MIT 许可见 许可证。

1import { join } from "node:path";
2import { Agent, type AgentMessage, setDefaultStreamFn, type ThinkingLevel } from "@earendil-works/pi-agent-core";
3import type { ModelsSimpleStreamOptions } from "@earendil-works/pi-ai";
4import { clampThinkingLevel, type Message, type Model, streamSimple } from "@earendil-works/pi-ai/compat";
5import { getAgentDir } from "../config.ts";
6import { resolvePath } from "../utils/paths.ts";
7import { AgentSession } from "./agent-session.ts";
8import { formatNoModelsAvailableMessage } from "./auth-guidance.ts";
9import { CacheWarmer } from "./cache-warmer.ts";
10import { DEFAULT_THINKING_LEVEL } from "./defaults.ts";
11import type { ExtensionRunner, LoadExtensionsResult, SessionStartEvent, ToolDefinition } from "./extensions/index.ts";
12import { convertToLlm } from "./messages.ts";
13import { findInitialModel } from "./model-resolver.ts";
14import { ModelRuntime } from "./model-runtime.ts";
15import { mergeProviderAttributionHeaders } from "./provider-attribution.ts";
16import type { ResourceLoader } from "./resource-loader.ts";
17import { DefaultResourceLoader } from "./resource-loader.ts";
18import { getDefaultSessionDir, SessionManager } from "./session-manager.ts";
19import { DEFAULT_TOOL_NAMES, SettingsManager } from "./settings-manager.ts";
20import { time } from "./timings.ts";
21import {
22 createBashTool,
23 createCodingTools,
24 createEditTool,
25 createFindTool,
26 createGrepTool,
27 createLsTool,
28 createPowerShellTool,
29 createReadOnlyTools,
30 createReadTool,
31 createWriteTool,
32 withFileMutationQueue,
33} from "./tools/index.ts";
34import { getBranchSelection } from "./virtual-models.ts";
35
36// Preserve the pre-0.81 fallback for extensions that construct Agent instances
37// or invoke low-level agent loops without supplying streamFn. Agent core remains
38// provider-agnostic and does not import pi-ai/compat itself.
39setDefaultStreamFn(streamSimple);
40
41export interface CreateAgentSessionOptions {
42 /** Working directory for project-local discovery. Default: process.cwd() */
43 cwd?: string;
44 /** Global config directory. Default: ~/.pi/agent */
45 agentDir?: string;
46
47 /** Canonical model/auth runtime. Defaults to a runtime using agentDir/auth.json and models.json. */
48 modelRuntime?: ModelRuntime;
49
50 /** Model to use. Default: from settings, else first available */
51 model?: Model<any>;
52 /** Thinking level. Default: from settings, else 'medium' (clamped to model capabilities) */
53 thinkingLevel?: ThinkingLevel;
54 /** Models available for cycling (Ctrl+P in interactive mode) */
55 scopedModels?: Array<{ model: Model<any>; thinkingLevel?: ThinkingLevel }>;
56
57 /**
58 * Optional default tool suppression mode when no explicit allowlist is provided.
59 *
60 * - "all": start with no tools enabled
61 * - "builtin": disable the default built-in tools (read, bash, edit, write)
62 * but keep extension/custom tools enabled
63 */
64 noTools?: "all" | "builtin";
65 /**
66 * Optional allowlist of tool names.
67 *
68 * When omitted, pi uses the resolved `defaultTools` setting for the initial
69 * selection when configured. Otherwise it enables the default built-in tools
70 * (read, bash, edit, write). Extension/custom tools remain enabled unless
71 * `noTools` changes that default. When provided, only the listed tool names are
72 * enabled.
73 */
74 tools?: string[];
75 /** Optional denylist of tool names to disable. Applies after `tools` when both are provided. */
76 excludeTools?: string[];
77 /** Custom tools to register (in addition to built-in tools). */
78 customTools?: ToolDefinition[];
79
80 /** Resource loader. When omitted, DefaultResourceLoader is used. */
81 resourceLoader?: ResourceLoader;
82
83 /** Session manager. Default: SessionManager.create(cwd) */
84 sessionManager?: SessionManager;
85
86 /** Settings manager. Default: SettingsManager.create(cwd, agentDir) */
87 settingsManager?: SettingsManager;
88 /** Session start event metadata for extension runtime startup. */
89 sessionStartEvent?: SessionStartEvent;
90}
91
92/** Result from createAgentSession */
93export interface CreateAgentSessionResult {
94 /** The created session */
95 session: AgentSession;
96 /** Extensions result (for UI context setup in interactive mode) */
97 extensionsResult: LoadExtensionsResult;
98 /** Warning if session was restored with a different model than saved */
99 modelFallbackMessage?: string;
100}
101
102// Re-exports
103
104export * from "./agent-session-runtime.ts";
105export type {
106 ExtensionAPI,
107 ExtensionCommandContext,
108 ExtensionContext,
109 ExtensionFactory,
110 InlineExtension,
111 SlashCommandInfo,
112 SlashCommandSource,
113 ToolDefinition,
114} from "./extensions/index.ts";
115export type { PromptTemplate } from "./prompt-templates.ts";
116export type { Skill } from "./skills.ts";
117export type { Tool } from "./tools/index.ts";
118
119export {
120 withFileMutationQueue,
121 // Tool factories (for custom cwd)
122 createCodingTools,
123 createReadOnlyTools,
124 createReadTool,
125 createBashTool,
126 createEditTool,
127 createWriteTool,
128 createGrepTool,
129 createFindTool,
130 createLsTool,
131 createPowerShellTool,
132};
133
134// Helper Functions
135
136function getDefaultAgentDir(): string {
137 return getAgentDir();
138}
139
140/**
141 * Create an AgentSession with the specified options.
142 *
143 * @example
144 * ```typescript
145 * // Minimal - uses defaults
146 * const { session } = await createAgentSession();
147 *
148 * // With explicit model
149 * import { getModel } from '@earendil-works/pi-ai';
150 * const { session } = await createAgentSession({
151 * model: getModel('anthropic', 'claude-opus-4-5'),
152 * thinkingLevel: 'high',
153 * });
154 *
155 * // Continue previous session
156 * const { session, modelFallbackMessage } = await createAgentSession({
157 * continueSession: true,
158 * });
159 *
160 * // Full control
161 * const loader = new DefaultResourceLoader({
162 * cwd: process.cwd(),
163 * agentDir: getAgentDir(),
164 * settingsManager: SettingsManager.create(),
165 * });
166 * await loader.reload();
167 * const { session } = await createAgentSession({
168 * model: myModel,
169 * tools: ["read", "bash"],
170 * resourceLoader: loader,
171 * sessionManager: SessionManager.inMemory(),
172 * });
173 * ```
174 */
175export async function createAgentSession(options: CreateAgentSessionOptions = {}): Promise<CreateAgentSessionResult> {
176 const cwd = resolvePath(options.cwd ?? options.sessionManager?.getCwd() ?? process.cwd());
177 const agentDir = options.agentDir ? resolvePath(options.agentDir) : getDefaultAgentDir();
178 let resourceLoader = options.resourceLoader;
179
180 const authPath = options.agentDir ? join(agentDir, "auth.json") : undefined;
181 const modelsPath = options.agentDir ? join(agentDir, "models.json") : undefined;
182 const modelRuntime = options.modelRuntime ?? (await ModelRuntime.create({ authPath, modelsPath }));
183
184 const settingsManager = options.settingsManager ?? SettingsManager.create(cwd, agentDir);
185 const sessionManager = options.sessionManager ?? SessionManager.create(cwd, getDefaultSessionDir(cwd, agentDir));
186
187 if (!resourceLoader) {
188 resourceLoader = new DefaultResourceLoader({ cwd, agentDir, settingsManager });
189 await resourceLoader.reload();
190 time("resourceLoader.reload");
191 }
192
193 // Check if session has existing data to restore
194 const existingSession = sessionManager.buildSessionContext();
195 const hasExistingSession = existingSession.messages.length > 0;
196 const hasThinkingEntry = sessionManager.getBranch().some((entry) => entry.type === "thinking_level_change");
197
198 let model = options.model;
199 let modelFallbackMessage: string | undefined;
200
201 // Assistant messages name the physical model that answered, so a virtual selection is only in
202 // model_change entries.
203 const sessionModel = getBranchSelection(sessionManager.getBranch(), (provider, modelId) =>
204 modelRuntime.getModel(provider, modelId),
205 );
206
207 // If session has data, try to restore model from it
208 if (!model && hasExistingSession && sessionModel) {
209 const restoredModel = modelRuntime.getModel(sessionModel.provider, sessionModel.modelId);
210 if (restoredModel && modelRuntime.hasConfiguredAuth(restoredModel.provider)) {
211 model = restoredModel;
212 }
213 if (!model) {
214 modelFallbackMessage = `Could not restore model ${sessionModel.provider}/${sessionModel.modelId}`;
215 }
216 }
217
218 // If still no model, use findInitialModel (checks settings default, then provider defaults)
219 if (!model) {
220 const result = await findInitialModel({
221 scopedModels: [],
222 isContinuing: hasExistingSession,
223 defaultProvider: settingsManager.getDefaultProvider(),
224 defaultModelId: settingsManager.getDefaultModel(),
225 defaultThinkingLevel: settingsManager.getDefaultThinkingLevel(),
226 modelThinkingLevels: settingsManager.getAllModelThinkingLevels(),
227 modelRuntime,
228 });
229 model = result.model;
230 if (!model) {
231 modelFallbackMessage = formatNoModelsAvailableMessage();
232 } else if (modelFallbackMessage) {
233 modelFallbackMessage += `. Using ${model.provider}/${model.id}`;
234 }
235 }
236
237 let thinkingLevel = options.thinkingLevel;
238
239 // If session has data, restore thinking level from it
240 if (thinkingLevel === undefined && hasExistingSession) {
241 thinkingLevel = hasThinkingEntry
242 ? (existingSession.thinkingLevel as ThinkingLevel)
243 : (settingsManager.getDefaultThinkingLevel() ?? DEFAULT_THINKING_LEVEL);
244 }
245
246 // Fall back to per-model override, then global default
247 if (thinkingLevel === undefined && model) {
248 const perModel = settingsManager.getModelThinkingLevel(model.provider, model.id);
249 if (perModel) {
250 thinkingLevel = perModel;
251 }
252 }
253 if (thinkingLevel === undefined) {
254 thinkingLevel = settingsManager.getDefaultThinkingLevel() ?? DEFAULT_THINKING_LEVEL;
255 }
256
257 // Clamp to model capabilities
258 if (!model) {
259 thinkingLevel = "off";
260 } else {
261 thinkingLevel = clampThinkingLevel(model, thinkingLevel) as ThinkingLevel;
262 }
263
264 const configuredDefaultToolNames = settingsManager.getDefaultTools();
265 const allowedToolNames = options.tools ?? (options.noTools === "all" ? [] : undefined);
266 const excludedToolNames = options.excludeTools;
267 const excludedToolNameSet = excludedToolNames ? new Set(excludedToolNames) : undefined;
268 const initialActiveToolNames = (
269 options.tools ?? (options.noTools ? [] : (configuredDefaultToolNames ?? DEFAULT_TOOL_NAMES))
270 ).filter((name) => !excludedToolNameSet?.has(name));
271
272 // Create convertToLlm wrapper that filters images if blockImages is enabled (defense-in-depth)
273 const convertToLlmWithBlockImages = (messages: AgentMessage[]): Message[] => {
274 const converted = convertToLlm(messages);
275 // Check setting dynamically so mid-session changes take effect
276 if (!settingsManager.getBlockImages()) {
277 return converted;
278 }
279 // Filter out ImageContent from all messages, replacing with text placeholder
280 return converted.map((msg) => {
281 if (msg.role === "user" || msg.role === "toolResult") {
282 const content = msg.content;
283 if (Array.isArray(content)) {
284 const hasImages = content.some((c) => c.type === "image");
285 if (hasImages) {
286 const filteredContent = content
287 .map((c) =>
288 c.type === "image" ? { type: "text" as const, text: "Image reading is disabled." } : c,
289 )
290 .filter(
291 (c, i, arr) =>
292 // Dedupe consecutive "Image reading is disabled." texts
293 !(
294 c.type === "text" &&
295 c.text === "Image reading is disabled." &&
296 i > 0 &&
297 arr[i - 1].type === "text" &&
298 (arr[i - 1] as { type: "text"; text: string }).text === "Image reading is disabled."
299 ),
300 );
301 return { ...msg, content: filteredContent };
302 }
303 }
304 }
305 return msg;
306 });
307 };
308
309 const extensionRunnerRef: { current?: ExtensionRunner } = {};
310 const cacheWarmer = new CacheWarmer(
311 modelRuntime,
312 sessionManager,
313 () => settingsManager.getCacheWarmingMode(),
314 async (event) => extensionRunnerRef.current?.emitCacheWarmingDecision(event) ?? event.action,
315 );
316 const buildRequestOptions = (
317 requestModel: Model<any>,
318 options: ModelsSimpleStreamOptions = {},
319 ): ModelsSimpleStreamOptions => {
320 const providerRetrySettings = settingsManager.getProviderRetrySettings();
321 const httpIdleTimeoutMs = settingsManager.getHttpIdleTimeoutMs();
322 const effectiveTimeoutMs = httpIdleTimeoutMs === 0 ? 2147483647 : httpIdleTimeoutMs;
323 const headerRunner = extensionRunnerRef.current;
324 return {
325 ...options,
326 timeoutMs: options.timeoutMs ?? providerRetrySettings.timeoutMs ?? effectiveTimeoutMs,
327 websocketConnectTimeoutMs: options.websocketConnectTimeoutMs ?? settingsManager.getWebSocketConnectTimeoutMs(),
328 maxRetries: options.maxRetries ?? providerRetrySettings.maxRetries,
329 maxRetryDelayMs: options.maxRetryDelayMs ?? providerRetrySettings.maxRetryDelayMs,
330 transformHeaders: async (requestHeaders) => {
331 const headers = mergeProviderAttributionHeaders(
332 requestModel,
333 settingsManager,
334 options.sessionId,
335 requestHeaders,
336 );
337 return headerRunner?.hasHandlers("before_provider_headers")
338 ? headerRunner.emitBeforeProviderHeaders(headers ?? {})
339 : (headers ?? {});
340 },
341 };
342 };
343 // Warm only requests for the selected model. Requests a virtual selection routed, or that an
344 // extension redirected, may not be repeated by the next request, so warming them could be wasted.
345 const cacheContextIsCurrent = (requestModel: Model<any>) => {
346 const messages = agent.state.messages;
347 return () => {
348 const currentModel = agent.state.model;
349 const currentMessages = agent.state.messages;
350 return (
351 currentModel.provider === requestModel.provider &&
352 currentModel.id === requestModel.id &&
353 messages.length <= currentMessages.length &&
354 messages.every((message, index) => currentMessages[index] === message)
355 );
356 };
357 };
358 const transformProviderPayload = async (payload: unknown) => {
359 const runner = extensionRunnerRef.current;
360 if (!runner?.hasHandlers("before_provider_request")) return payload;
361 return runner.emitBeforeProviderRequest(payload);
362 };
363 const handleProviderResponse: NonNullable<ModelsSimpleStreamOptions["onResponse"]> = async (response) => {
364 const runner = extensionRunnerRef.current;
365 if (!runner?.hasHandlers("after_provider_response")) return;
366 await runner.emit({
367 type: "after_provider_response",
368 status: response.status,
369 headers: response.headers,
370 });
371 };
372 const handleProviderStreamEvent: NonNullable<ModelsSimpleStreamOptions["onProviderStreamEvent"]> = async (
373 data,
374 model,
375 ) => {
376 const runner = extensionRunnerRef.current;
377 if (!runner?.hasHandlers("provider_stream_event")) return;
378 await runner.emit({
379 data,
380 type: "provider_stream_event",
381 provider: model.provider,
382 api: model.api,
383 model: model.id,
384 });
385 };
386
387 const agent = new Agent({
388 initialState: {
389 systemPrompt: "",
390 model,
391 thinkingLevel,
392 tools: [],
393 messages: existingSession.messages,
394 },
395 convertToLlm: convertToLlmWithBlockImages,
396 streamFn: async (model, context, options) => {
397 const requestOptions = buildRequestOptions(model, options);
398 // Compaction and summaries use their own routing ids; only session requests
399 // replace the cache entry, so warming restarts from them. Keep warming while
400 // the current transcript still extends the request's prefix. Agent state may
401 // shallow-copy the messages array or refresh the model object without changing
402 // the provider request, so top-level object identity is not a valid cache key.
403 if (options?.sessionId === sessionManager.getSessionId()) {
404 cacheWarmer.start({ model, context, options: requestOptions }, cacheContextIsCurrent(model));
405 }
406 return modelRuntime.streamSimple(model, context, requestOptions);
407 },
408 onPayload: transformProviderPayload,
409 onResponse: handleProviderResponse,
410 onProviderStreamEvent: handleProviderStreamEvent,
411 sessionId: sessionManager.getSessionId(),
412 transformContext: async (messages) => {
413 const runner = extensionRunnerRef.current;
414 if (!runner) return messages;
415 return runner.emitContext(messages);
416 },
417 steeringMode: settingsManager.getSteeringMode(),
418 followUpMode: settingsManager.getFollowUpMode(),
419 transport: settingsManager.getTransport(),
420 thinkingBudgets: settingsManager.getThinkingBudgets(),
421 maxRetryDelayMs: settingsManager.getProviderRetrySettings().maxRetryDelayMs,
422 });
423
424 // Restore missing settings metadata for older sessions.
425 if (hasExistingSession) {
426 if (!hasThinkingEntry) {
427 sessionManager.appendThinkingLevelChange(thinkingLevel);
428 }
429 } else {
430 // Save initial model and thinking level for new sessions so they can be restored on resume
431 if (model) {
432 sessionManager.appendModelChange(model.provider, model.id);
433 }
434 sessionManager.appendThinkingLevelChange(thinkingLevel);
435 }
436
437 const session = new AgentSession({
438 agent,
439 sessionManager,
440 settingsManager,
441 cwd,
442 scopedModels: options.scopedModels,
443 resourceLoader,
444 customTools: options.customTools,
445 modelRuntime,
446 cacheWarmer,
447 initialActiveToolNames,
448 usesDefaultTools: options.tools === undefined && !options.noTools,
449 allowedToolNames,
450 excludedToolNames,
451 extensionRunnerRef,
452 sessionStartEvent: options.sessionStartEvent,
453 });
454
455 const extensionsResult = resourceLoader.getExtensions();
456
457 return {
458 session,
459 extensionsResult,
460 modelFallbackMessage,
461 };
462}