merge: resolve conflicts with main branch

- Merge auth-profiles feature from main into runner.ts
- Merge closeCallbacks feature from main into async-agent.ts
- Regenerate pnpm-lock.yaml with new dependencies

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Jiang Bohan 2026-02-03 18:57:05 +08:00
commit dafbf856ac
83 changed files with 12239 additions and 258 deletions

View file

@ -3,23 +3,64 @@ import { v7 as uuidv7 } from "uuid";
import type { AgentOptions, AgentRunResult } from "./types.js";
import { createAgentOutput } from "./cli/output.js";
import { resolveModel, resolveTools } from "./tools.js";
import {
resolveApiKey,
resolveApiKeyForProfile,
resolveApiKeyForProvider,
resolveBaseUrl,
resolveModelId,
} 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 {
resolveApiKey,
resolveBaseUrl,
resolveModelId,
isOAuthProvider,
getLoginInstructions,
} from "./providers/index.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 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;
@ -31,52 +72,83 @@ export class Agent {
private readonly debug: boolean;
private toolsOptions: AgentOptions;
private readonly originalToolsConfig?: ToolsConfig;
private readonly stderr: NodeJS.WritableStream;
private initialized = false;
// Auth profile rotation state
private readonly 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;
const stderr = options.logger?.stderr ?? process.stderr;
this.output = createAgentOutput({ stdout, stderr });
this.stderr = options.logger?.stderr ?? process.stderr;
this.output = createAgentOutput({ stdout, stderr: this.stderr });
this.debug = options.debug ?? false;
// Resolve provider and model from options > env vars > defaults
const resolvedProvider = options.provider ?? credentialManager.getLlmProvider() ?? "kimi-coding";
const resolvedModel = resolveModelId(resolvedProvider, options.model);
const apiKey = resolveApiKey(resolvedProvider, options.apiKey);
// Validate credentials before proceeding
if (!apiKey) {
if (isOAuthProvider(resolvedProvider)) {
// OAuth provider without valid credentials - show login instructions
const instructions = getLoginInstructions(resolvedProvider);
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(
`Provider "${resolvedProvider}" requires authentication.\n\n` +
`${instructions}\n\n` +
`After logging in, run: multica --provider ${resolvedProvider}`,
`authProfileId provider mismatch: authProfileId="${options.authProfileId}" ` +
`does not match provider="${options.provider}"`,
);
}
// API Key provider without key - show configuration instructions
throw new Error(
`Provider "${resolvedProvider}" requires an API key.\n\n` +
`Add your API key to: ${getCredentialsPath()}\n\n` +
`Example:\n` +
`{\n` +
` "llm": {\n` +
` "provider": "${resolvedProvider}",\n` +
` "providers": {\n` +
` "${resolvedProvider}": {\n` +
` "apiKey": "your-api-key-here"\n` +
` }\n` +
` }\n` +
` }\n` +
`}`,
);
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(
{ getApiKey: (_provider: string) => apiKey },
this.currentApiKey
? { getApiKey: (_provider: string) => this.currentApiKey! }
: {},
);
// Load Agent Profile (if profileId is specified)
@ -124,7 +196,7 @@ export class Agent {
return tempSession.getMeta();
})();
const effectiveProvider = resolvedModel ? resolvedProvider : (options.provider ?? storedMeta?.provider);
const effectiveProvider = resolvedModel ? this.resolvedProvider : (options.provider ?? storedMeta?.provider);
const effectiveModel = resolvedModel ?? options.model ?? storedMeta?.model;
let model = resolveModel({ ...options, provider: effectiveProvider, model: effectiveModel });
@ -150,7 +222,7 @@ export class Agent {
// 警告context window 较小
if (this.contextWindowGuard.shouldWarn) {
stderr.write(
this.stderr.write(
`[Context Window Guard] WARNING: Low context window: ${this.contextWindowGuard.tokens} tokens (source: ${this.contextWindowGuard.source})\n`,
);
}
@ -167,7 +239,9 @@ export class Agent {
const compactionMode = options.compactionMode ?? "tokens"; // 默认使用 token 模式
// 获取 API Key用于 summary 模式)
const summaryApiKey = compactionMode === "summary" ? resolveApiKey(model.provider, options.apiKey) : undefined;
const summaryApiKey = compactionMode === "summary"
? resolveApiKey(this.resolvedProvider, options.apiKey)
: undefined;
// 创建 SessionManager带 context window 配置)
this.session = new SessionManager({
@ -211,31 +285,6 @@ export class Agent {
}
this.agent.setTools(tools);
const restoredMessages = this.session.loadMessages();
if (restoredMessages.length > 0) {
if (this.debug) {
console.error(`[debug] Restoring ${restoredMessages.length} messages from session`);
for (const msg of restoredMessages) {
const msgAny = msg as any;
const content = Array.isArray(msgAny.content)
? msgAny.content.map((c: any) => c.type || "text").join(", ")
: typeof msgAny.content;
console.error(`[debug] ${msg.role}: ${content}`);
if (Array.isArray(msgAny.content)) {
for (const block of msgAny.content) {
if (block.type === "tool_use") {
console.error(`[debug] tool_use id: ${block.id}, name: ${block.name}`);
}
if (block.type === "tool_result") {
console.error(`[debug] tool_result tool_use_id: ${block.tool_use_id}`);
}
}
}
}
}
this.agent.replaceMessages(restoredMessages);
}
this.session.saveMeta({
provider: this.agent.state.model?.provider,
model: this.agent.state.model?.id,
@ -247,19 +296,128 @@ export class Agent {
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 agent events (returns unsubscribe function) */
/** Subscribe to raw AgentEvent from the underlying engine */
subscribe(fn: (event: AgentEvent) => void): () => void {
return this.agent.subscribe(fn);
}
async run(prompt: string): Promise<AgentRunResult> {
if (!this.initialized) {
await this.session.repairIfNeeded((msg) => console.error(msg));
const restoredMessages = this.session.loadMessages();
if (restoredMessages.length > 0) {
if (this.debug) {
console.error(`[debug] Restoring ${restoredMessages.length} messages from session`);
for (const msg of restoredMessages) {
const msgAny = msg as any;
const content = Array.isArray(msgAny.content)
? msgAny.content.map((c: any) => c.type || "text").join(", ")
: typeof msgAny.content;
console.error(`[debug] ${msg.role}: ${content}`);
if (Array.isArray(msgAny.content)) {
for (const block of msgAny.content) {
if (block.type === "tool_use") {
console.error(`[debug] tool_use id: ${block.id}, name: ${block.name}`);
}
if (block.type === "tool_result") {
console.error(`[debug] tool_result tool_use_id: ${block.tool_use_id}`);
}
}
}
}
}
this.agent.replaceMessages(restoredMessages);
}
this.initialized = true;
}
this.output.state.lastAssistantText = "";
await this.agent.prompt(prompt);
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);
}
return { text: this.output.state.lastAssistantText, 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;