# 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
893 lines
30 KiB
TypeScript
893 lines
30 KiB
TypeScript
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,
|
||
});
|
||
}
|
||
}
|