multica/src/agent/runner.ts
Naiyuan Qing 23905daaa1 Merge remote-tracking branch 'origin/main' into feat/telegram-channel
# Conflicts:
#	apps/desktop/electron/electron-env.d.ts
#	apps/desktop/electron/ipc/index.ts
#	apps/desktop/electron/preload.ts
#	apps/desktop/src/App.tsx
#	apps/desktop/src/pages/layout.tsx
#	src/agent/async-agent.ts
#	src/agent/runner.ts
#	src/hub/hub.ts
2026-02-09 13:44:08 +08:00

893 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { Agent as PiAgentCore, type AgentEvent, type AgentMessage } from "@mariozechner/pi-agent-core";
import { v7 as uuidv7 } from "uuid";
import type { AgentOptions, AgentRunResult, ReasoningMode } from "./types.js";
import type { MulticaEvent } from "./events.js";
import { createAgentOutput } from "./cli/output.js";
import { resolveModel, resolveTools, type ResolveToolsOptions } from "./tools.js";
import {
resolveApiKey,
resolveApiKeyForProfile,
resolveApiKeyForProvider,
resolveBaseUrl,
resolveModelId,
PROVIDER_ALIAS,
getDefaultModel,
} from "./providers/index.js";
import { SessionManager } from "./session/session-manager.js";
import { ProfileManager } from "./profile/index.js";
import { SkillManager } from "./skills/index.js";
import { credentialManager, getCredentialsPath } from "./credentials.js";
import {
checkContextWindow,
DEFAULT_CONTEXT_TOKENS,
type ContextWindowGuardResult,
} from "./context-window/index.js";
import { mergeToolsConfig, type ToolsConfig } from "./tools/policy.js";
import {
loadAuthProfileStore,
resolveAuthProfileOrder,
isProfileInCooldown,
markAuthProfileFailure,
markAuthProfileUsed,
markAuthProfileGood,
} from "./auth-profiles/index.js";
import {
buildSystemPrompt as buildStructuredSystemPrompt,
collectRuntimeInfo,
type SystemPromptMode,
} from "./system-prompt/index.js";
import type { AuthProfileFailureReason } from "./auth-profiles/index.js";
// ============================================================
// Error classification for auth profile rotation
// ============================================================
/** Classify an error into an auth profile failure reason */
export function classifyError(error: unknown): AuthProfileFailureReason {
const msg = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
if (msg.includes("401") || msg.includes("403") || msg.includes("unauthorized") || msg.includes("invalid api key") || msg.includes("authentication")) {
return "auth";
}
if (msg.includes("400") || msg.includes("invalid request") || msg.includes("malformed") || msg.includes("bad request") || msg.includes("schema")) {
return "format";
}
if (msg.includes("429") || msg.includes("rate limit") || msg.includes("rate_limit") || msg.includes("too many requests")) {
return "rate_limit";
}
if (msg.includes("billing") || msg.includes("quota") || msg.includes("insufficient") || msg.includes("payment")) {
return "billing";
}
if (msg.includes("timeout") || msg.includes("timed out") || msg.includes("econnreset") || msg.includes("etimedout")) {
return "timeout";
}
return "unknown";
}
/** Check if an error is potentially retryable via profile rotation */
export function isRotatableError(reason: AuthProfileFailureReason): boolean {
// timeout is rotatable because some providers hang on rate limit instead of returning 429
return reason === "auth" || reason === "rate_limit" || reason === "billing" || reason === "timeout";
}
export class Agent {
private readonly agent: PiAgentCore;
private output;
private readonly session: SessionManager;
private readonly profile?: ProfileManager;
private readonly skillManager?: SkillManager;
private readonly contextWindowGuard: ContextWindowGuardResult;
private readonly debug: boolean;
private reasoningMode: ReasoningMode;
private toolsOptions: ResolveToolsOptions;
private readonly originalToolsConfig?: ToolsConfig;
private readonly stderr: NodeJS.WritableStream;
private initialized = false;
// Internal run state
private _internalRun = false;
private _runMutex: Promise<void> = Promise.resolve();
// MulticaEvent subscribers (parallel to PiAgentCore's subscriber list)
// Typed as AgentEvent | MulticaEvent to match subscribeAll() callback signature
private multicaListeners: Array<(event: AgentEvent | MulticaEvent) => void> = [];
// Auth profile rotation state
private resolvedProvider: string;
private currentApiKey: string | undefined;
private currentProfileId: string | undefined;
private profileCandidates: string[];
private profileIndex: number;
private readonly pinnedProfile: boolean;
/** Current session ID */
readonly sessionId: string;
constructor(options: AgentOptions = {}) {
const stdout = options.logger?.stdout ?? process.stdout;
this.stderr = options.logger?.stderr ?? process.stderr;
this.debug = options.debug ?? false;
this.reasoningMode = options.reasoningMode ?? "stream";
this.output = createAgentOutput({ stdout, stderr: this.stderr, reasoningMode: this.reasoningMode });
// Resolve provider and model from options > env vars > defaults
const defaultProvider = options.provider ?? credentialManager.getLlmProvider() ?? "kimi-coding";
if (options.authProfileId) {
const profileProvider = options.authProfileId.includes(":")
? options.authProfileId.split(":")[0]!
: options.authProfileId;
if (options.provider && options.provider !== profileProvider) {
throw new Error(
`authProfileId provider mismatch: authProfileId="${options.authProfileId}" ` +
`does not match provider="${options.provider}"`,
);
}
this.resolvedProvider = profileProvider;
} else {
this.resolvedProvider = defaultProvider;
}
const resolvedModel = resolveModelId(this.resolvedProvider, options.model);
// === Auth profile resolution ===
this.pinnedProfile = !!(options.authProfileId || options.apiKey);
if (options.apiKey) {
// Explicit API key — no rotation
this.currentApiKey = options.apiKey;
this.currentProfileId = this.resolvedProvider;
this.profileCandidates = [];
this.profileIndex = 0;
} else if (options.authProfileId) {
// Pinned profile — no rotation
this.currentApiKey = resolveApiKeyForProfile(options.authProfileId)
?? resolveApiKey(this.resolvedProvider);
this.currentProfileId = options.authProfileId;
this.profileCandidates = [];
this.profileIndex = 0;
} else {
// Profile-aware resolution with rotation support
const resolved = resolveApiKeyForProvider(this.resolvedProvider);
if (resolved) {
this.currentApiKey = resolved.apiKey;
this.currentProfileId = resolved.profileId;
} else {
this.currentApiKey = undefined;
this.currentProfileId = undefined;
}
// Load full candidate list for rotation
const store = loadAuthProfileStore();
this.profileCandidates = resolveAuthProfileOrder(this.resolvedProvider, store);
this.profileIndex = this.currentProfileId
? Math.max(0, this.profileCandidates.indexOf(this.currentProfileId))
: 0;
}
this.agent = new PiAgentCore(
this.currentApiKey
? { getApiKey: (_provider: string) => this.currentApiKey! }
: {},
);
// Load Agent Profile (if profileId is specified)
// Every Agent should have a Profile for memory, tools config, and other settings
if (options.profileId) {
this.profile = new ProfileManager({
profileId: options.profileId,
baseDir: options.profileBaseDir,
});
// Ensure profile directory exists (creates with default templates if new)
this.profile.getOrCreateProfile(true);
}
// Initialize SkillManager (enabled by default)
if (options.enableSkills !== false) {
this.skillManager = new SkillManager({
profileId: options.profileId,
profileBaseDir: options.profileBaseDir,
config: options.skills,
});
}
this.sessionId = options.sessionId ?? uuidv7();
// 解析 model用于获取 context window
const storedMeta = (() => {
// 临时创建 session 获取 meta避免循环依赖
const tempSession = new SessionManager({ sessionId: this.sessionId });
return tempSession.getMeta();
})();
const effectiveProvider = resolvedModel ? this.resolvedProvider : (options.provider ?? storedMeta?.provider);
const effectiveModel = resolvedModel ?? options.model ?? storedMeta?.model;
let model = resolveModel({ ...options, provider: effectiveProvider, model: effectiveModel });
if (!model) {
throw new Error(
`Unknown model: provider="${effectiveProvider}", model="${effectiveModel}". ` +
`Check ${getCredentialsPath()} for llm.provider and llm.providers.${effectiveProvider}.model.`,
);
}
// Override base URL if provided via options or environment variable
const baseUrl = resolveBaseUrl(model.provider, options.baseUrl);
if (baseUrl) {
model = { ...model, baseUrl };
}
// === Context Window Guard ===
this.contextWindowGuard = checkContextWindow({
modelContextWindow: model.contextWindow,
configContextTokens: options.contextWindowTokens,
defaultTokens: DEFAULT_CONTEXT_TOKENS,
});
// 警告context window 较小
if (this.contextWindowGuard.shouldWarn) {
this.stderr.write(
`[Context Window Guard] WARNING: Low context window: ${this.contextWindowGuard.tokens} tokens (source: ${this.contextWindowGuard.source})\n`,
);
}
// 阻止context window 太小
if (this.contextWindowGuard.shouldBlock) {
throw new Error(
`[Context Window Guard] Context window too small: ${this.contextWindowGuard.tokens} tokens. ` +
`Minimum required: 16,000 tokens. Please use a model with a larger context window.`,
);
}
// 确定 compaction 模式
const compactionMode = options.compactionMode ?? "tokens"; // 默认使用 token 模式
// 获取 API Key用于 summary 模式)
const summaryApiKey = compactionMode === "summary"
? resolveApiKey(this.resolvedProvider, options.apiKey)
: undefined;
// 创建 SessionManager带 context window 配置)
this.session = new SessionManager({
sessionId: this.sessionId,
compactionMode,
// Token 模式参数
contextWindowTokens: this.contextWindowGuard.tokens,
// systemPrompt is set later via setSystemPrompt() after tools are resolved
reserveTokens: options.reserveTokens,
targetRatio: options.compactionTargetRatio,
minKeepMessages: options.minKeepMessages,
// Summary 模式参数
model: compactionMode === "summary" ? model : undefined,
apiKey: summaryApiKey,
customInstructions: options.summaryInstructions,
});
if (!options.thinkingLevel && storedMeta?.thinkingLevel) {
this.agent.setThinkingLevel(storedMeta.thinkingLevel as any);
} else if (options.thinkingLevel) {
this.agent.setThinkingLevel(options.thinkingLevel);
}
// Resolve reasoningMode: options > profile config > storedMeta > default "stream"
if (!options.reasoningMode) {
const profileReasoningMode = this.profile?.getProfile()?.config?.reasoningMode;
const metaReasoningMode = storedMeta?.reasoningMode as ReasoningMode | undefined;
const resolved = profileReasoningMode ?? metaReasoningMode ?? "stream";
if (resolved !== this.reasoningMode) {
this.reasoningMode = resolved;
// Re-create output with correct reasoningMode
this.output = createAgentOutput({ stdout, stderr: this.stderr, reasoningMode: this.reasoningMode });
}
}
this.agent.setModel(model);
// Save original tools config from options (for later merging during reload)
if (options.tools) {
this.originalToolsConfig = options.tools;
}
// Merge Profile tools config with options.tools (options takes precedence)
const profileToolsConfig = this.profile?.getToolsConfig();
const mergedToolsConfig = mergeToolsConfig(profileToolsConfig, options.tools);
const profileDir = this.profile?.getProfileDir();
this.toolsOptions = mergedToolsConfig
? { ...options, tools: mergedToolsConfig, profileDir }
: { ...options, profileDir };
const tools = resolveTools(this.toolsOptions);
if (this.debug) {
if (profileToolsConfig) {
console.error(`[debug] Profile tools config: ${JSON.stringify(profileToolsConfig)}`);
}
console.error(`[debug] Merged tools config: ${JSON.stringify(mergedToolsConfig)}`);
console.error(`[debug] Resolved ${tools.length} tools: ${tools.map(t => t.name).join(", ") || "(none)"}`);
}
this.agent.setTools(tools);
// Build the system prompt using the structured builder
const toolNames = tools.map((t: { name: string }) => t.name);
const systemPrompt = this.buildFullSystemPrompt(options, toolNames);
if (systemPrompt) {
this.agent.setSystemPrompt(systemPrompt);
this.session.setSystemPrompt(systemPrompt);
}
this.session.saveMeta({
provider: this.agent.state.model?.provider,
model: this.agent.state.model?.id,
thinkingLevel: this.agent.state.thinkingLevel,
reasoningMode: this.reasoningMode,
contextWindowTokens: this.contextWindowGuard.tokens,
});
this.agent.subscribe((event: AgentEvent) => {
this.output.handleEvent(event);
this.handleSessionEvent(event);
});
if (this.debug && this.currentProfileId) {
console.error(`[debug] Auth profile: ${this.currentProfileId} (pinned=${this.pinnedProfile}, candidates=${this.profileCandidates.length})`);
}
}
/** Subscribe to raw AgentEvent from the underlying engine */
subscribe(fn: (event: AgentEvent) => void): () => void {
return this.agent.subscribe(fn);
}
/** Subscribe to both AgentEvent and MulticaEvent streams */
subscribeAll(fn: (event: AgentEvent | MulticaEvent) => void): () => void {
const unsubCore = this.agent.subscribe(fn);
this.multicaListeners.push(fn);
return () => {
unsubCore();
const idx = this.multicaListeners.indexOf(fn);
if (idx >= 0) this.multicaListeners.splice(idx, 1);
};
}
emitMulticaEvent(event: MulticaEvent): void {
for (const fn of this.multicaListeners) {
try {
fn(event);
} catch {
// Don't let listener errors break the agent loop
}
}
}
async run(prompt: string): Promise<AgentRunResult> {
// Run-level mutex: prevents concurrent run/runInternal from mis-tagging messages
return this.withRunMutex(() => this._run(prompt));
}
/**
* Run a prompt as an internal turn.
* Messages are persisted with `internal: true` and rolled back from
* in-memory state after the turn completes, so they do not pollute
* the main conversation context.
*/
async runInternal(prompt: string): Promise<AgentRunResult> {
return this.withRunMutex(async () => {
const messageCountBefore = this.agent.state.messages.length;
this._internalRun = true;
try {
const result = await this._run(prompt);
return result;
} finally {
this._internalRun = false;
// Roll back internal messages from in-memory state
const current = this.agent.state.messages;
if (current.length > messageCountBefore) {
this.agent.replaceMessages(current.slice(0, messageCountBefore));
}
}
});
}
private async withRunMutex<T>(fn: () => Promise<T>): Promise<T> {
// Chain on the mutex so only one run executes at a time
const prev = this._runMutex;
let resolve: () => void;
this._runMutex = new Promise<void>((r) => { resolve = r; });
await prev;
try {
return await fn();
} finally {
resolve!();
}
}
private async _run(prompt: string): Promise<AgentRunResult> {
await this.ensureInitialized();
this.output.state.lastAssistantText = "";
const canRotate = !this.pinnedProfile && this.profileCandidates.length > 1;
let lastError: unknown;
// Loop to exhaust all candidate profiles on rotatable errors
while (true) {
try {
await this.agent.prompt(prompt);
break; // success — exit loop
} catch (error) {
lastError = error;
const reason = classifyError(error);
if (this.currentProfileId && isRotatableError(reason)) {
markAuthProfileFailure(this.currentProfileId, reason);
}
if (!canRotate || !this.currentProfileId) throw error;
if (!isRotatableError(reason)) throw error;
if (this.debug) {
this.stderr.write(
`[auth-profile] Profile "${this.currentProfileId}" failed (${reason}), attempting rotation...\n`,
);
}
if (!this.advanceAuthProfile()) {
throw lastError; // All profiles exhausted
}
if (this.debug) {
this.stderr.write(
`[auth-profile] Rotated to profile "${this.currentProfileId}"\n`,
);
}
// Reset output for retry
this.output.state.lastAssistantText = "";
// continue loop with new profile
}
}
// Mark success
if (this.currentProfileId) {
markAuthProfileUsed(this.currentProfileId);
markAuthProfileGood(this.resolvedProvider, this.currentProfileId);
}
const thinking = this.reasoningMode !== "off"
? this.output.state.lastAssistantThinking || undefined
: undefined;
return { text: this.output.state.lastAssistantText, thinking, error: this.agent.state.error };
}
/**
* Advance to the next non-cooldown auth profile.
* Returns true if a new profile was activated, false if exhausted.
*/
private advanceAuthProfile(): boolean {
const store = loadAuthProfileStore();
const startIndex = this.profileIndex;
for (let i = 1; i < this.profileCandidates.length; i++) {
const nextIndex = (startIndex + i) % this.profileCandidates.length;
const candidateId = this.profileCandidates[nextIndex] as string | undefined;
if (!candidateId) continue;
// Skip profiles in cooldown
const stats = store.usageStats?.[candidateId];
if (stats && isProfileInCooldown(stats)) continue;
// Try to resolve API key
const apiKey = resolveApiKeyForProfile(candidateId);
if (!apiKey) continue;
this.currentApiKey = apiKey;
this.currentProfileId = candidateId;
this.profileIndex = nextIndex;
return true;
}
return false;
}
private handleSessionEvent(event: AgentEvent) {
if (event.type === "message_end") {
const message = event.message as AgentMessage;
this.session.saveMessage(message, this._internalRun ? { internal: true } : undefined);
// Skip compaction during internal runs — internal messages will be
// rolled back from memory afterwards, so compacting now would be incorrect.
if (message.role === "assistant" && !this._internalRun) {
void this.maybeCompact();
}
}
}
private async maybeCompact() {
const messages = this.agent.state.messages.slice();
if (!this.session.needsCompaction(messages)) return;
try {
const result = await this.session.maybeCompact(messages);
if (!result) return;
this.emitMulticaEvent({ type: "compaction_start" });
if (result?.kept) {
this.agent.replaceMessages(result.kept);
}
this.emitMulticaEvent({
type: "compaction_end",
removed: result?.removedCount ?? 0,
kept: result?.kept.length ?? messages.length,
tokensRemoved: result?.tokensRemoved,
tokensKept: result?.tokensKept,
reason: result?.reason ?? "tokens",
});
} catch (err) {
throw err;
}
}
/**
* Wait for all pending session storage writes to complete.
*/
async flushSession(): Promise<void> {
await this.session.flush();
}
/**
* Reload tools from profile config.
* Call this after updating tool status to apply changes
* without restarting the agent session.
*/
reloadTools(): string[] {
// Re-read profile tools config to get latest changes
const profileToolsConfig = this.profile?.getToolsConfig();
console.log(`[Agent] reloadTools: profileToolsConfig =`, JSON.stringify(profileToolsConfig));
const mergedToolsConfig = mergeToolsConfig(profileToolsConfig, this.originalToolsConfig);
console.log(`[Agent] reloadTools: mergedToolsConfig =`, JSON.stringify(mergedToolsConfig));
this.toolsOptions = mergedToolsConfig
? { ...this.toolsOptions, tools: mergedToolsConfig }
: this.toolsOptions;
const tools = resolveTools(this.toolsOptions);
console.log(`[Agent] reloadTools: resolved ${tools.length} tools: ${tools.map(t => t.name).join(", ") || "(none)"}`);
this.agent.setTools(tools);
if (this.debug) {
console.error(`[debug] Reloaded ${tools.length} tools: ${tools.map(t => t.name).join(", ") || "(none)"}`);
}
return tools.map(t => t.name);
}
/** Get current active tool names */
getActiveTools(): string[] {
return this.agent.state.tools?.map(t => t.name) ?? [];
}
/** Whether the agent is currently executing an internal run */
get isInternalRun(): boolean {
return this._internalRun;
}
/**
* Persist a synthetic assistant message into both in-memory state and session JSONL.
* Used after an internal run to keep the LLM summary visible in future turns
* while the internal prompt stays hidden.
*/
persistAssistantSummary(text: string): void {
const model = this.agent.state.model;
const message = {
role: "assistant" as const,
content: [{ type: "text" as const, text }],
api: model?.api ?? "openai-completions",
provider: model?.provider ?? "internal",
model: model?.id ?? "unknown",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop" as const,
timestamp: Date.now(),
};
this.agent.appendMessage(message);
this.session.saveMessage(message);
}
/** Ensure session messages are loaded from disk (idempotent) */
async ensureInitialized(): Promise<void> {
if (this.initialized) return;
await this.session.repairIfNeeded((msg) => console.error(msg));
const restoredMessages = this.session.loadMessages();
if (restoredMessages.length > 0) {
this.agent.replaceMessages(restoredMessages);
}
this.initialized = true;
}
/** Get all messages from the current session (in-memory state) */
getMessages(): AgentMessage[] {
return this.agent.state.messages.slice();
}
/**
* Load messages from session storage with filtering.
* By default, internal messages are excluded.
*/
loadSessionMessages(options?: { includeInternal?: boolean }): AgentMessage[] {
return this.session.loadMessages(options);
}
/**
* Get all skills with their eligibility status.
* Returns empty array if skills are disabled.
*/
getSkillsWithStatus(): Array<{
id: string;
name: string;
description: string;
source: string;
eligible: boolean;
reasons?: string[] | undefined;
}> {
if (!this.skillManager) {
return [];
}
return this.skillManager.listAllSkillsWithStatus();
}
/**
* Get eligible skills only.
* Returns empty array if skills are disabled.
*/
getEligibleSkills(): Array<{
id: string;
name: string;
description: string;
source: string;
}> {
if (!this.skillManager) {
return [];
}
return this.skillManager.listSkills();
}
/**
* Reload skills from disk.
* Call this after adding/removing skills to apply changes.
*/
reloadSkills(): void {
if (this.skillManager) {
this.skillManager.reload();
}
}
/**
* Set a tool's enabled status and persist to profile config.
* Returns the new tools config, or undefined if no profile is loaded.
*/
setToolStatus(toolName: string, enabled: boolean): { allow?: string[]; deny?: string[] } | undefined {
if (!this.profile) {
return undefined;
}
const newConfig = this.profile.setToolEnabled(toolName, enabled);
// Reload tools to apply changes
this.reloadTools();
// Build result object, only including defined properties
const result: { allow?: string[]; deny?: string[] } = {};
if (newConfig.allow) result.allow = newConfig.allow;
if (newConfig.deny) result.deny = newConfig.deny;
return result;
}
/**
* Get current profile ID, if any.
*/
getProfileId(): string | undefined {
return this.profile?.getProfile()?.id;
}
/**
* Get profile directory path, if profile is enabled.
*/
getProfileDir(): string | undefined {
return this.profile?.getProfileDir();
}
/**
* Get heartbeat configuration from profile config.
*/
getHeartbeatConfig():
| {
enabled?: boolean | undefined;
every?: string | undefined;
prompt?: string | undefined;
ackMaxChars?: number | undefined;
}
| undefined {
return this.profile?.getHeartbeatConfig();
}
/**
* Get agent display name from profile config.
*/
getAgentName(): string | undefined {
return this.profile?.getName();
}
/**
* Update agent display name in profile config.
*/
setAgentName(name: string): void {
this.profile?.updateName(name);
}
/**
* Get user.md content from profile.
*/
getUserContent(): string | undefined {
return this.profile?.getUserContent();
}
/**
* Update user.md content in profile.
*/
setUserContent(content: string): void {
this.profile?.updateUserContent(content);
}
/**
* Get agent communication style from profile config.
*/
getAgentStyle(): string | undefined {
return this.profile?.getStyle();
}
/**
* Update agent communication style in profile config.
*/
setAgentStyle(style: string): void {
this.profile?.updateStyle(style);
}
/**
* Get current provider and model information.
*/
getProviderInfo(): { provider: string; model: string | undefined } {
return {
provider: this.resolvedProvider,
model: this.agent.state.model?.id,
};
}
/**
* Switch to a different provider and/or model.
* This updates the agent's model without recreating the session.
*/
setProvider(providerId: string, modelId?: string): { provider: string; model: string | undefined } {
// Resolve the actual provider (handle aliases like claude-code -> anthropic)
const actualProvider = PROVIDER_ALIAS[providerId] ?? providerId;
// Resolve the model
const targetModel = modelId ?? getDefaultModel(providerId) ?? getDefaultModel(actualProvider);
const model = resolveModel({ provider: providerId, model: targetModel });
if (!model) {
throw new Error(`Failed to resolve model for provider: ${providerId}, model: ${targetModel}`);
}
// Resolve API key for the new provider
// For OAuth providers (claude-code, openai-codex), we need to use the original providerId
// because OAuth credentials are resolved by the original provider name, not the alias
const resolved = resolveApiKeyForProvider(providerId);
if (resolved) {
this.currentApiKey = resolved.apiKey;
this.currentProfileId = resolved.profileId;
} else {
// Fallback: try with actual provider (for API key based providers)
this.currentApiKey = resolveApiKey(actualProvider);
this.currentProfileId = actualProvider;
}
if (!this.currentApiKey) {
throw new Error(`No API key configured for provider: ${providerId}`);
}
// Update the agent's model and API key
const baseUrl = resolveBaseUrl(actualProvider);
const modelWithBaseUrl = baseUrl ? { ...model, baseUrl } : model;
this.agent.setModel(modelWithBaseUrl);
// Update internal state
this.resolvedProvider = providerId;
// Update session metadata
this.session.saveMeta({
provider: actualProvider,
model: model.id,
thinkingLevel: this.agent.state.thinkingLevel,
reasoningMode: this.reasoningMode,
contextWindowTokens: this.contextWindowGuard.tokens,
});
return {
provider: providerId,
model: model.id,
};
}
/**
* Build the full system prompt using the structured builder.
* Combines profile content, tools, skills, and runtime info.
*/
private buildFullSystemPrompt(
options: AgentOptions,
toolNames: string[],
): string | undefined {
const skillsPrompt = this.skillManager?.buildSkillsPrompt();
// If a raw systemPrompt is provided directly, use it as-is (backward compat)
if (!options.profileId && options.systemPrompt) {
return skillsPrompt
? `${options.systemPrompt}\n\n${skillsPrompt}`
: options.systemPrompt;
}
if (!this.profile?.getProfile() && !options.profileId) {
return skillsPrompt || undefined;
}
return this.rebuildSystemPrompt(toolNames);
}
/**
* Reload profile from disk and rebuild system prompt.
* Call this after updating profile files to apply changes immediately.
*/
reloadSystemPrompt(): void {
if (!this.profile) {
return;
}
this.profile.reloadProfile();
const toolNames = (this.agent.state.tools ?? []).map((t: { name: string }) => t.name);
const systemPrompt = this.rebuildSystemPrompt(toolNames);
if (systemPrompt) {
this.agent.setSystemPrompt(systemPrompt);
this.session.setSystemPrompt(systemPrompt);
}
}
/**
* Rebuild system prompt from current state.
* Shared by constructor (via buildFullSystemPrompt) and reloadSystemPrompt.
*/
private rebuildSystemPrompt(toolNames: string[]): string | undefined {
const profile = this.profile?.getProfile();
if (!profile) return undefined;
const skillsPrompt = this.skillManager?.buildSkillsPrompt();
const runtime = collectRuntimeInfo({
agentName: this.profile?.getName(),
provider: this.resolvedProvider,
model: this.agent.state.model?.id,
});
return buildStructuredSystemPrompt({
mode: "full",
profile: {
soul: profile.soul,
user: profile.user,
workspace: profile.workspace,
memory: profile.memory,
heartbeat: profile.heartbeat,
config: profile.config,
},
profileDir: this.profile!.getProfileDir(),
tools: toolNames,
skillsPrompt,
runtime,
});
}
}