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:
commit
dafbf856ac
83 changed files with 12239 additions and 258 deletions
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue